Compare commits
36 Commits
v1.0.8.beta9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d6dcd198c | |||
| 27d83f193f | |||
| f4c2b8de4c | |||
| d454ecc7cf | |||
| 7ede964c4c | |||
| 4b50af3ae0 | |||
| 65293c4f81 | |||
| a41341e732 | |||
| ecfa50359b | |||
| 2879336ce4 | |||
| f706968618 | |||
| 69e198f745 | |||
| 70f907d909 | |||
| 3a3d17e9b4 | |||
| d560c9f678 | |||
| 7a9b49794c | |||
| e3f8dc8aac | |||
| 1228c30a75 | |||
| 4762e71308 | |||
| 2082ee3e68 | |||
| 10cb7d7ac6 | |||
| 261153067c | |||
| 19a13a0ad2 | |||
| 5d7f79a619 | |||
| 0934994f51 | |||
| ef61d405ba | |||
| 60165e4851 | |||
| 39cb44bc22 | |||
| 9fa83657e3 | |||
| 5a244c329e | |||
| 93f696d4df | |||
| 988cc7be6f | |||
| b890929d0b | |||
| adbf885d95 | |||
| 99c58a3d5f | |||
| c89f7e9bbb |
@@ -4,7 +4,7 @@ permissions:
|
||||
on: [release]
|
||||
env:
|
||||
IMAGE: RecordingTheSmile/likedream-go
|
||||
REGISTRY: git.likedream.xyz
|
||||
REGISTRY: mycoder.cc.cd
|
||||
jobs:
|
||||
Build-And-Upload:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+3
-1
@@ -4,4 +4,6 @@
|
||||
node_modules
|
||||
.claude
|
||||
*.exe
|
||||
.serena
|
||||
.serena
|
||||
|
||||
device_code_cfworker
|
||||
@@ -0,0 +1,127 @@
|
||||
when:
|
||||
event: tag
|
||||
|
||||
clone:
|
||||
git:
|
||||
image: woodpeckerci/plugin-git
|
||||
settings:
|
||||
remote: http://10.10.10.128:3000/becomer/likedream.git
|
||||
|
||||
steps:
|
||||
build-image:
|
||||
image: docker
|
||||
commands:
|
||||
- docker build -t likedream-go:latest .
|
||||
- docker tag likedream-go:latest likedream-go:${CI_COMMIT_TAG:-${CI_COMMIT_SHA:0:8}}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
deploy:
|
||||
image: docker
|
||||
commands: |
|
||||
# ============================================================
|
||||
# 蓝绿部署:零宕机切换 likedream-go 容器
|
||||
# 判断当前代理使用的端口,另一个作为备用
|
||||
# ============================================================
|
||||
|
||||
set -e
|
||||
|
||||
IMAGE_TAG="$CI_COMMIT_TAG"
|
||||
if [ -z "$IMAGE_TAG" ]; then IMAGE_TAG="latest"; fi
|
||||
|
||||
# 确保网络存在
|
||||
docker network create likedream-net 2>/dev/null || true
|
||||
|
||||
# 读取 1Panel 当前代理端口
|
||||
CURRENT_PORT=$(docker exec 1Panel-openresty-ESmn \
|
||||
grep -o 'proxy_pass http://10.10.10.128:[0-9]\+' \
|
||||
/www/sites/tianshen/proxy/api.conf | grep -o '[0-9]\+$')
|
||||
echo "=== 当前 1Panel 反代端口: $CURRENT_PORT ==="
|
||||
|
||||
if [ "$CURRENT_PORT" = "8091" ]; then
|
||||
PORT_ACTIVE=8091
|
||||
PORT_STANDBY=8092
|
||||
elif [ "$CURRENT_PORT" = "8092" ]; then
|
||||
PORT_ACTIVE=8092
|
||||
PORT_STANDBY=8091
|
||||
else
|
||||
echo "未知当前端口 $CURRENT_PORT,默认使用 8091/8092"
|
||||
PORT_ACTIVE=8091
|
||||
PORT_STANDBY=8092
|
||||
fi
|
||||
echo " 活跃: $PORT_ACTIVE | 备用: $PORT_STANDBY"
|
||||
|
||||
# 清理之前可能残留的临时容器
|
||||
docker rm -f likedream-go-new 2>/dev/null || true
|
||||
|
||||
echo "=== 蓝绿部署: 启动新容器 on :$PORT_STANDBY (image: $IMAGE_TAG) ==="
|
||||
|
||||
docker run -d --name likedream-go-new \
|
||||
--restart unless-stopped \
|
||||
--network likedream-net \
|
||||
-p $PORT_STANDBY:8091 \
|
||||
-v /opt/likedream-go/config.toml:/app/config.toml:ro \
|
||||
likedream-go:$IMAGE_TAG
|
||||
|
||||
# 健康检查:等待新容器就绪
|
||||
HEALTHY=false
|
||||
for i in $(seq 1 15); do
|
||||
if wget -q --timeout=3 -O /dev/null http://10.10.10.128:$PORT_STANDBY/health 2>/dev/null; then
|
||||
HEALTHY=true
|
||||
break
|
||||
fi
|
||||
echo " 等待新容器就绪... ($i/15)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ "$HEALTHY" = false ]; then
|
||||
echo "ERROR: 新容器未通过健康检查,回滚"
|
||||
docker stop likedream-go-new
|
||||
docker rm likedream-go-new
|
||||
exit 1
|
||||
fi
|
||||
echo " 新容器已就绪 ✓"
|
||||
|
||||
# 切换 1Panel 代理到备用端口
|
||||
echo "=== 切换 1Panel 反代 :$PORT_ACTIVE → :$PORT_STANDBY ==="
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_ACTIVE|:$PORT_STANDBY|g" \
|
||||
/www/sites/tianshen/proxy/api.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成(零中断)✓"
|
||||
|
||||
# 停止旧容器
|
||||
echo "=== 清理旧容器 ==="
|
||||
docker stop likedream-go 2>/dev/null || true
|
||||
docker rm likedream-go 2>/dev/null || true
|
||||
|
||||
# 在活跃端口启动正式容器
|
||||
echo "=== 启动正式容器 on :$PORT_ACTIVE ==="
|
||||
docker run -d --name likedream-go \
|
||||
--restart unless-stopped \
|
||||
--network likedream-net \
|
||||
-p $PORT_ACTIVE:8091 \
|
||||
-v /opt/likedream-go/config.toml:/app/config.toml:ro \
|
||||
likedream-go:$IMAGE_TAG
|
||||
|
||||
# 连接到验证码服务网络
|
||||
docker network connect ocr-go_default likedream-go 2>/dev/null || true
|
||||
|
||||
# 切回 1Panel 代理到活跃端口
|
||||
echo "=== 切回 1Panel 反代 :$PORT_STANDBY → :$PORT_ACTIVE ==="
|
||||
docker exec 1Panel-openresty-ESmn \
|
||||
sed -i "s|:$PORT_STANDBY|:$PORT_ACTIVE|g" \
|
||||
/www/sites/tianshen/proxy/api.conf
|
||||
docker exec 1Panel-openresty-ESmn nginx -s reload
|
||||
echo " Nginx 热加载完成(零中断)✓"
|
||||
|
||||
# 清理临时容器和旧镜像
|
||||
echo "=== 清理 ==="
|
||||
docker rm -f likedream-go-new 2>/dev/null || true
|
||||
# 保留 latest 和最近 3 个 tag 镜像
|
||||
docker images likedream-go --format '{{.Tag}}' | grep -v latest | head -n -3 | while read tag; do
|
||||
docker rmi likedream-go:$tag 2>/dev/null || true
|
||||
done
|
||||
echo "=== 部署完成 ✓ ==="
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
+8
-14
@@ -1,24 +1,18 @@
|
||||
# ---- 前端构建 ----
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /build/new-web
|
||||
COPY new-web/package.json new-web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY new-web/ .
|
||||
RUN npm run build
|
||||
|
||||
# ---- 后端构建 ----
|
||||
FROM golang:1.24-alpine AS backend-builder
|
||||
FROM golang:1.25-alpine AS backend-builder
|
||||
WORKDIR /build
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
COPY . .
|
||||
|
||||
RUN go build -ldflags="-s -w" -o bin/server
|
||||
|
||||
RUN apk add --no-cache upx
|
||||
|
||||
RUN upx --best --lzma bin/server
|
||||
|
||||
# ---- 最终镜像 ----
|
||||
FROM alpine:latest
|
||||
WORKDIR /app
|
||||
COPY --from=backend-builder /build/bin/server /app/server
|
||||
COPY --from=frontend-builder /build/new-web/dist /app/public
|
||||
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
CMD ["./server"]
|
||||
RUN ls -a
|
||||
CMD ["./server"]
|
||||
+18
-4
@@ -1,6 +1,18 @@
|
||||
[ocr]
|
||||
url = "https://devocr.likedream.xyz"
|
||||
|
||||
# ===== 应用品牌配置 =====
|
||||
# brand: "default"(老系统/若梦系统/MD3风格邮件) 或 "new"(新系统/天神系统/shadcn风格邮件)
|
||||
# 未设置时默认使用 "default"
|
||||
[app]
|
||||
brand = "default"
|
||||
name = "若梦系统"
|
||||
copyright = "© 2025 若梦系统. 保留所有权利."
|
||||
# 邮件中的链接地址(会覆盖 server 段中的同名配置)
|
||||
# emailConfirmBaseUrl = "https://ujob.us.ci/confirm/"
|
||||
# emailResetPasswordUrl = "https://ujob.us.ci/reset-password/"
|
||||
# balanceMigrateUrl = "https://ujob.us.ci/balance-migrate/"
|
||||
|
||||
[quesbank]
|
||||
url = "http://tk.swk.tw/api/search.php"
|
||||
key = "sk-acacac439b346036df45e90c85c177c375568ef72c469e49"
|
||||
@@ -8,8 +20,9 @@ key = "sk-acacac439b346036df45e90c85c177c375568ef72c469e49"
|
||||
[database]
|
||||
type = "mysql"
|
||||
dsn = "test_likedream_go:EpXckXmxawDEQGxJ@tcp(database.out:3306)/test_likedream_go?parseTime=true&loc=Asia%2FShanghai"
|
||||
dsn2 = "host=database.out user=dev password=JPmKjmpcaG3EYAJZ dbname=dev port=5432 sslmode=disable TimeZone=Asia/Shanghai"
|
||||
# host=localhost user=gorm password=gorm dbname=gorm port=9920 sslmode=disable TimeZone=Asia/Shanghai
|
||||
dsn2 = "host=pg.out user=dev password=JPmKjmpcaG3EYAJZ dbname=dev port=5432 sslmode=disable TimeZone=Asia/Shanghai"
|
||||
#type = "postgres"
|
||||
#dsn = "host=pg.out user=dev password=JPmKjmpcaG3EYAJZ dbname=dev port=5432 sslmode=disable TimeZone=Asia/Shanghai"
|
||||
[server]
|
||||
port = 8091
|
||||
url = "https://devocr.likedream.xyz"
|
||||
@@ -23,8 +36,9 @@ balanceMigrateUrl = "http://localhost:3200/balance-migrate/"
|
||||
mode = "dev"
|
||||
|
||||
[captcha]
|
||||
baseUrl = "http://captcha-resolver.out"
|
||||
#baseUrl = "http://localhost:8890"
|
||||
#baseUrl = "http://captcha-resolver.out"
|
||||
baseUrl = "http://likedream-go-captcha:8890"
|
||||
apiKey = "baca122..."
|
||||
[redis]
|
||||
url = "redis://:jhkdjhkjdhsIUTYURTU_pEGmFP@database.out:56379"
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
services:
|
||||
likedream-go:
|
||||
image: likedream-go:latest
|
||||
container_name: likedream-go
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8091:8091"
|
||||
volumes:
|
||||
- /opt/likedream-go/config.toml:/app/config.toml:ro
|
||||
networks:
|
||||
- likedream-net
|
||||
- ocr-go_default
|
||||
|
||||
networks:
|
||||
likedream-net:
|
||||
external: true
|
||||
ocr-go_default:
|
||||
external: true
|
||||
@@ -1,6 +1,6 @@
|
||||
module likedream-goframe
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
entgo.io/ent v0.14.5
|
||||
@@ -28,10 +28,12 @@ require (
|
||||
github.com/wenlng/go-captcha-assets v1.0.7
|
||||
github.com/wenlng/go-captcha/v2 v2.0.4
|
||||
github.com/wneessen/go-mail v0.6.2
|
||||
github.com/xuri/excelize/v2 v2.10.0
|
||||
go.uber.org/automaxprocs v1.6.0
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/net v0.46.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
resty.dev/v3 v3.0.0-beta.3
|
||||
@@ -100,7 +102,6 @@ require (
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/xuri/efp v0.0.1 // indirect
|
||||
github.com/xuri/excelize/v2 v2.10.0 // indirect
|
||||
github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zclconf/go-cty v1.14.4 // indirect
|
||||
|
||||
@@ -200,8 +200,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
@@ -281,10 +281,10 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/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=
|
||||
|
||||
+15
-17
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"likedream-goframe/internal/common/cst"
|
||||
"likedream-goframe/internal/controller/agent"
|
||||
"likedream-goframe/internal/controller/balance"
|
||||
@@ -26,56 +27,53 @@ import (
|
||||
|
||||
func StartServer(ctx context.Context) {
|
||||
s := g.Server()
|
||||
// 设置端口
|
||||
|
||||
port, err := g.Cfg().Get(ctx, cst.ConfigServerPort)
|
||||
utils.OkOrPanic(err)
|
||||
|
||||
s.SetNameToUriType(3)
|
||||
// 设置中间件
|
||||
s.Use(
|
||||
middleware.ExceptionHandler,
|
||||
)
|
||||
s.Use(middleware.ExceptionHandler)
|
||||
s.BindStatusHandler(404, common.NewBaseV1().NotFound)
|
||||
// 业务路由
|
||||
|
||||
s.Group("/v1", func(group *ghttp.RouterGroup) {
|
||||
group.Bind(user_login.NewUserLoginV1())
|
||||
// 系统设置路由
|
||||
group.Bind(config.NewMailOAuthCtrlV1())
|
||||
|
||||
systemGroup := group.Group("/system").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
|
||||
systemGroup.Bind(role.NewV1())
|
||||
systemGroup.Bind(menu.NewV1())
|
||||
systemGroup.Bind(user.NewUserCtrlV1())
|
||||
systemGroup.Bind(config.NewConfigCtrlV1())
|
||||
systemGroup.Bind(config.NewAIConfigCtrlV1())
|
||||
//systemGroup.Bind(agent.NewV1())
|
||||
// 用户信息路由
|
||||
|
||||
userGroup := group.Group("/user").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
|
||||
userGroup.Bind(user_login.NewUserInfoV1())
|
||||
userGroup.Bind(balance.NewBalanceCtrlV1())
|
||||
// 代理信息路由
|
||||
|
||||
agentGroup := group.Group("/agent").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
|
||||
agentGroup.Bind(agent.NewV1())
|
||||
agentGroup.Bind(card.NewCardCtrlV1())
|
||||
// 今日校园
|
||||
|
||||
cpdailyGroup := group.Group("/cpdaily").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
|
||||
cpdailyGroup.Bind(cpdaily.NewCpUserCtrlV1())
|
||||
cpdailyGroup.Bind(cpdaily.NewCpTaskCtrlV1())
|
||||
cpdailyGroup.Bind(cpdaily.NewCpImageCtrlV1())
|
||||
cpdailyGroup.Bind(cpdaily.NewCpTaskLogCtrlV1())
|
||||
cpdailyGroup.Bind(cpdaily.NewCpLeaveCtrlV1())
|
||||
// 娱乐
|
||||
|
||||
musicGroup := group.Group("/music").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
|
||||
musicGroup.Bind(music.NewMusicCtrlV1())
|
||||
// 网课
|
||||
|
||||
courseGroup := group.Group("/course").Middleware(middleware.AuthHandler, middleware.PermissionHandler)
|
||||
courseGroup.Bind(course.NewXxtCtrlV1())
|
||||
courseGroup.Bind(course.NewSmartEduSCCtrlV1())
|
||||
})
|
||||
|
||||
s.SetPort(port.Int())
|
||||
|
||||
s.GetOpenApi().Config.IgnorePkgPath = true
|
||||
// 设置全局异常捕获
|
||||
e := s.Start()
|
||||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||||
|
||||
err = s.Start()
|
||||
utils.OkOrPanic(err, oops.WithContext(ctx).Wrap)
|
||||
}
|
||||
|
||||
func StopServer() {
|
||||
|
||||
@@ -24,6 +24,62 @@ type ConfigListReq struct {
|
||||
}
|
||||
|
||||
type ConfigPublicGetReq struct {
|
||||
g.Meta `path:"/config/public/{key}" method:"get" tags:"Config" summary:"获取公开配置(无需认证)" auth:"false"`
|
||||
g.Meta `path:"/config/public/{key}" method:"get" tags:"Config" summary:"获取公开配置" auth:"false"`
|
||||
Key string `json:"key" v:"required#配置键不能为空"`
|
||||
}
|
||||
|
||||
type SmtpConfigGetReq struct {
|
||||
g.Meta `path:"/config/smtp" method:"get" tags:"Config" summary:"获取邮件配置" permission:"sys:config:edit"`
|
||||
}
|
||||
|
||||
type SmtpConfigUpdateReq struct {
|
||||
g.Meta `path:"/config/smtp" method:"put" tags:"Config" summary:"更新邮件配置" permission:"sys:config:edit"`
|
||||
|
||||
AuthMode string `json:"authMode" v:"required#请选择邮件认证模式"`
|
||||
|
||||
SmtpHost string `json:"smtpHost"`
|
||||
SmtpPort int `json:"smtpPort"`
|
||||
SmtpUsername string `json:"smtpUsername"`
|
||||
SmtpPassword string `json:"smtpPassword"`
|
||||
|
||||
FromEmail string `json:"fromEmail" v:"required|email#发件人邮箱不能为空|请输入正确的发件人邮箱"`
|
||||
FromName string `json:"fromName" v:"required#发件人名称不能为空"`
|
||||
|
||||
OAuthTenant string `json:"oauthTenant"`
|
||||
OAuthClientID string `json:"oauthClientId"`
|
||||
OAuthClientSecret string `json:"oauthClientSecret"`
|
||||
OAuthRedirectURL string `json:"oauthRedirectUrl"`
|
||||
OAuthRefreshToken string `json:"oauthRefreshToken"`
|
||||
OAuthScope string `json:"oauthScope"`
|
||||
}
|
||||
|
||||
type SmtpTestReq struct {
|
||||
g.Meta `path:"/config/smtp/test" method:"post" tags:"Config" summary:"发送测试邮件" permission:"sys:config:edit"`
|
||||
ToEmail string `json:"toEmail" v:"required|email#收件人邮箱不能为空|请输入正确的收件人邮箱"`
|
||||
}
|
||||
|
||||
type OutlookOAuthAuthorizeURLReq struct {
|
||||
g.Meta `path:"/config/smtp/oauth/outlook/authorize-url" method:"post" tags:"Config" summary:"获取 Outlook OAuth2 授权地址" permission:"sys:config:edit"`
|
||||
|
||||
OAuthTenant string `json:"oauthTenant"`
|
||||
OAuthClientID string `json:"oauthClientId" v:"required#Client ID 不能为空"`
|
||||
OAuthClientSecret string `json:"oauthClientSecret" v:"required#Client Secret 不能为空"`
|
||||
OAuthScope string `json:"oauthScope"`
|
||||
FromEmail string `json:"fromEmail" v:"required|email#发件人邮箱不能为空|请输入正确的发件人邮箱"`
|
||||
FrontendOrigin string `json:"frontendOrigin" v:"required#前端来源地址不能为空"`
|
||||
CallbackBaseURL string `json:"callbackBaseUrl" v:"required#API 基础地址不能为空"`
|
||||
}
|
||||
|
||||
type OutlookOAuthAuthorizeURLResp struct {
|
||||
AuthURL string `json:"authUrl"`
|
||||
CallbackURL string `json:"callbackUrl"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
type OutlookOAuthCallbackReq struct {
|
||||
g.Meta `path:"/mail/oauth/outlook/callback" method:"get" tags:"MailOAuth" summary:"Outlook OAuth2 回调" auth:"false"`
|
||||
Code string `json:"code"`
|
||||
State string `json:"state"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
|
||||
@@ -9,10 +9,13 @@ type TaskStatisticsItem struct {
|
||||
|
||||
// TaskStatisticsV2Item 每日任务统计项(新版:从日志表按任务id去重)
|
||||
type TaskStatisticsV2Item struct {
|
||||
Date string `json:"date"` // 日期 格式:2006-01-02
|
||||
SuccessTask int `json:"successTask"` // 成功任务数(任务id去重)
|
||||
FailTask int `json:"failTask"` // 失败任务数(任务id去重)
|
||||
PendingTask int `json:"pendingTask"` // 待执行任务数(仅当天)
|
||||
Date string `json:"date"` // 日期 格式:2006-01-02
|
||||
SuccessTask int `json:"successTask"` // 成功任务数(任务id去重)
|
||||
FailTask int `json:"failTask"` // 失败任务数(任务id去重)
|
||||
PendingTask int `json:"pendingTask"` // 待执行任务数(仅当天)
|
||||
SuccessTaskIds []int64 `json:"successTaskIds,omitempty"` // 成功任务ID列表
|
||||
FailTaskIds []int64 `json:"failTaskIds,omitempty"` // 失败任务ID列表
|
||||
PendingTaskIds []int64 `json:"pendingTaskIds,omitempty"` // 待执行任务ID列表(仅当天)
|
||||
}
|
||||
|
||||
// DashboardSummary 首页概览数据(新版)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
type CpLeaveListReq struct {
|
||||
g.Meta `path:"/leave" method:"get" tags:"CpLeave" summary:"获取销假记录列表"`
|
||||
g.Meta `path:"/leave" method:"get" tags:"CpLeave" summary:"Get leave records"`
|
||||
CpUserId int64 `json:"cpUserId"`
|
||||
Username string `json:"username"`
|
||||
Status string `json:"status"`
|
||||
@@ -15,23 +15,67 @@ type CpLeaveListReq struct {
|
||||
}
|
||||
|
||||
type GetLeaveListFromSystemReq struct {
|
||||
g.Meta `path:"/leave/system/{cpUserId}" method:"get" tags:"CpLeave" summary:"获取今日校园系统中的请假列表"`
|
||||
g.Meta `path:"/leave/system/{cpUserId}" method:"get" tags:"CpLeave" summary:"Get system leave list"`
|
||||
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
|
||||
}
|
||||
|
||||
type CpLeaveReportReq struct {
|
||||
g.Meta `path:"/leave/report" method:"post" tags:"CpLeave" summary:"执行销假"`
|
||||
g.Meta `path:"/leave/report" method:"post" tags:"CpLeave" summary:"Submit leave report"`
|
||||
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
|
||||
LeaveId string `json:"leaveId" v:"required#请假记录ID不能为空"`
|
||||
Title string `json:"title" v:"required#请假记录标题不能为空"`
|
||||
Longitude string `json:"longitude"` // 经度(可选,不传则使用系统默认)
|
||||
Latitude string `json:"latitude"` // 纬度(可选,不传则使用系统默认)
|
||||
Address string `json:"address"` // 地址(可选,不传则使用系统默认)
|
||||
Longitude string `json:"longitude"`
|
||||
Latitude string `json:"latitude"`
|
||||
Address string `json:"address"`
|
||||
ImageUrl string `json:"imageUrl"`
|
||||
}
|
||||
|
||||
type CpLeaveUploadReq struct {
|
||||
g.Meta `path:"/leave/upload" method:"post" tags:"CpLeave" summary:"上传销假图片"`
|
||||
g.Meta `path:"/leave/upload" method:"post" tags:"CpLeave" summary:"Upload leave image"`
|
||||
File *ghttp.UploadFile `json:"file"`
|
||||
CpUserId int64 `json:"cpUserId"`
|
||||
}
|
||||
|
||||
type CpLeaveCheckReq struct {
|
||||
g.Meta `path:"/leave/check/{cpUserId}" method:"get" tags:"CpLeave" summary:"Check leave permission"`
|
||||
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
|
||||
}
|
||||
|
||||
type CpLeaveTypesReq struct {
|
||||
g.Meta `path:"/leave/types/{cpUserId}" method:"get" tags:"CpLeave" summary:"Get leave types"`
|
||||
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
|
||||
}
|
||||
|
||||
type CpLeaveTypeCfgReq struct {
|
||||
g.Meta `path:"/leave/typeCfg" method:"post" tags:"CpLeave" summary:"Get leave type config"`
|
||||
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
|
||||
Code *int `json:"code"`
|
||||
}
|
||||
|
||||
type CpLeaveApproveFlowReq struct {
|
||||
g.Meta `path:"/leave/approveFlow" method:"post" tags:"CpLeave" summary:"Get approve flow"`
|
||||
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
|
||||
LeaveType *int `json:"leaveType"`
|
||||
StartTime string `json:"startTime" v:"required#开始时间不能为空"`
|
||||
EndTime string `json:"endTime" v:"required#结束时间不能为空"`
|
||||
}
|
||||
|
||||
type CpLeaveApplyReq struct {
|
||||
g.Meta `path:"/leave/apply" method:"post" tags:"CpLeave" summary:"Apply leave"`
|
||||
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
|
||||
LeaveType *int `json:"leaveType"`
|
||||
StartTime string `json:"startTime" v:"required#开始时间不能为空"`
|
||||
EndTime string `json:"endTime" v:"required#结束时间不能为空"`
|
||||
UrgencyMobile string `json:"urgencyMobile"`
|
||||
LeaveReason string `json:"leaveReason" v:"required#请假原因不能为空"`
|
||||
OutStatus bool `json:"outStatus"`
|
||||
Address string `json:"address"`
|
||||
DetailAddress string `json:"detailAddress"`
|
||||
Longitude string `json:"longitude" v:"required#经度不能为空"`
|
||||
Latitude string `json:"latitude" v:"required#纬度不能为空"`
|
||||
LocationAddr string `json:"locationAddr" v:"required#定位地址不能为空"`
|
||||
ImageUrl string `json:"imageUrl"`
|
||||
ApproverWid string `json:"approverWid"`
|
||||
ApproverId string `json:"approverId"`
|
||||
ApproverName string `json:"approverName"`
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type CpTaskListReq struct {
|
||||
TaskType cptask.TaskType `json:"type"`
|
||||
Username string `json:"username"`
|
||||
TaskStatus cptask.TaskStatus `json:"task_status"`
|
||||
Ids string `json:"ids"`
|
||||
Page int `json:"page" d:"1"`
|
||||
Size int `json:"size" d:"10"`
|
||||
}
|
||||
@@ -54,6 +55,12 @@ type PostCpTaskExecuteReq struct {
|
||||
ExecuteDays []int `json:"execute_days"`
|
||||
}
|
||||
|
||||
type UpdateTaskTitleReq struct {
|
||||
g.Meta `path:"/task/title/{id}" method:"put" tags:"CpTask" summary:"修改任务标题"`
|
||||
Id int64 `json:"id"`
|
||||
Title string `json:"title" v:"required#标题不能为空"`
|
||||
}
|
||||
|
||||
type DeleteTaskReq struct {
|
||||
g.Meta `path:"/task/{id}" method:"delete" tags:"CpTask" summary:"删除任务"`
|
||||
Id int64 `json:"id"`
|
||||
|
||||
@@ -91,3 +91,14 @@ type CpUserCustomDeviceReq struct {
|
||||
MobileCode string `json:"mobileCode" v:"required#请输入设备码"`
|
||||
MobileType string `json:"mobileType" v:"required|in:random,manual#请指定设备码类型|设备码类型无效"`
|
||||
}
|
||||
|
||||
// ---- 二维码获取设备码流程 ----
|
||||
|
||||
type CpUserGenerateQrReq struct {
|
||||
g.Meta `path:"/user/generateQr" method:"post" tags:"CpUser" summary:"生成设备码二维码"`
|
||||
}
|
||||
|
||||
type CpUserCheckQrReq struct {
|
||||
g.Meta `path:"/user/checkQr" method:"get" tags:"CpUser" summary:"校验二维码UUID是否有效" auth:"false"`
|
||||
Uuid string `json:"uuid" v:"required#请提供二维码UUID"`
|
||||
}
|
||||
|
||||
@@ -16,13 +16,14 @@ func ApiSemiOCRVerification(bytes []byte, shape1, shape2 int64) string {
|
||||
data := Bytes2Base64(bytes)
|
||||
ctx := gctx.GetInitCtx()
|
||||
baseUrl := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaBaseUrl).String() + "/semiOcr"
|
||||
apiKey := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaApiKey).String()
|
||||
client := httpclient.Cpdaily.Clone(ctx)
|
||||
client.SetDoNotParseResponse(false)
|
||||
post, err := client.R().SetBody(map[string]any{
|
||||
"base64": data,
|
||||
"shape1": shape1,
|
||||
"shape2": shape2,
|
||||
}).Post(baseUrl)
|
||||
}).SetHeader("X-Api-Key", apiKey).Post(baseUrl)
|
||||
utils.OkOrPanic(err, func(err error) error {
|
||||
return oops.
|
||||
With("SemiOCRVerification", baseUrl).
|
||||
@@ -37,11 +38,12 @@ func ApiAutoOCRVerification(bytes []byte) string {
|
||||
data := Bytes2Base64(bytes)
|
||||
ctx := gctx.GetInitCtx()
|
||||
baseUrl := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaBaseUrl).String() + "/ocr"
|
||||
apiKey := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaApiKey).String()
|
||||
client := httpclient.Cpdaily.Clone(ctx)
|
||||
client.SetDoNotParseResponse(false)
|
||||
post, err := client.R().SetBody(map[string]any{
|
||||
"base64": data,
|
||||
}).Post(baseUrl)
|
||||
}).SetHeader("X-Api-Key", apiKey).Post(baseUrl)
|
||||
utils.OkOrPanic(err, func(err error) error {
|
||||
return oops.
|
||||
With("AutoOCRVerification", baseUrl).
|
||||
@@ -55,11 +57,12 @@ func ApiAutoOCRVerification(bytes []byte) string {
|
||||
func ApiAutoCalcVerification(b64 string) string {
|
||||
ctx := gctx.GetInitCtx()
|
||||
baseUrl := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaBaseUrl).String() + "/calc"
|
||||
apiKey := g.Config().MustGetWithEnv(ctx, cst.ConfigCaptchaApiKey).String()
|
||||
client := httpclient.Cpdaily.Clone(ctx)
|
||||
client.SetDoNotParseResponse(false)
|
||||
post, err := client.R().SetBody(map[string]any{
|
||||
"base64": b64,
|
||||
}).Post(baseUrl)
|
||||
}).SetHeader("X-Api-Key", apiKey).Post(baseUrl)
|
||||
utils.OkOrPanic(err, func(err error) error {
|
||||
return oops.
|
||||
With("AutoOCRVerification", baseUrl).
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package leave
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
v1 "likedream-goframe/internal/api/v1"
|
||||
"likedream-goframe/internal/application/cpdaily"
|
||||
"likedream-goframe/internal/application/cpdaily/leave/model"
|
||||
"likedream-goframe/internal/application/cpdaily/taskhelper"
|
||||
"likedream-goframe/internal/common/errors"
|
||||
"likedream-goframe/internal/utils"
|
||||
|
||||
"github.com/gogf/gf/v2/container/gvar"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// CheckOnceLeave 检查是否允许请假
|
||||
func (c *CpdailyReportApp) CheckOnceLeave() error {
|
||||
post, err := c.client.R().SetBody(map[string]any{}).
|
||||
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/stu/checkOnceLeave", c.host))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := utils.ReadGjsonResult(post.Body)
|
||||
if result.Get("code").String() != "0" {
|
||||
return errors.NewBusinessError(400, result.Get("message").String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QueryLeaveTypes 获取请假类型列表
|
||||
func (c *CpdailyReportApp) QueryLeaveTypes() gjson.Result {
|
||||
post, err := c.client.R().SetBody(map[string]any{}).
|
||||
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/queryLeaveTypes", c.host))
|
||||
utils.OkOrPanic(err)
|
||||
result := utils.ReadGjsonResult(post.Body)
|
||||
if result.Get("code").String() != "0" {
|
||||
utils.OkOrPanic(errors.NewBusinessError(503, result.Get("message").String()))
|
||||
}
|
||||
return result.Get("datas.leaveType")
|
||||
}
|
||||
|
||||
// QueryLeaveTypeCfg 查询请假类型配置
|
||||
func (c *CpdailyReportApp) QueryLeaveTypeCfg(code int) gjson.Result {
|
||||
post, err := c.client.R().SetBody(map[string]any{"code": code}).
|
||||
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/stu/queryLeaveTypeCfg", c.host))
|
||||
utils.OkOrPanic(err)
|
||||
result := utils.ReadGjsonResult(post.Body)
|
||||
if result.Get("code").String() != "0" {
|
||||
utils.OkOrPanic(errors.NewBusinessError(503, result.Get("message").String()))
|
||||
}
|
||||
return result.Get("datas")
|
||||
}
|
||||
|
||||
// ObtainApproveFlow 获取审批人列表
|
||||
func (c *CpdailyReportApp) ObtainApproveFlow(leaveType int, startTime, endTime string) gjson.Result {
|
||||
post, err := c.client.R().SetBody(map[string]any{
|
||||
"leaveType": leaveType,
|
||||
"startTime": startTime,
|
||||
"endTime": endTime,
|
||||
}).Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/stu/obtainApproveFlow", c.host))
|
||||
utils.OkOrPanic(err)
|
||||
result := utils.ReadGjsonResult(post.Body)
|
||||
if result.Get("code").String() != "0" {
|
||||
utils.OkOrPanic(errors.NewBusinessError(503, result.Get("message").String()))
|
||||
}
|
||||
return result.Get("datas")
|
||||
}
|
||||
|
||||
// SubmitLeaveApply 提交请假申请
|
||||
func (c *CpdailyReportApp) SubmitLeaveApply(ctx context.Context, req *v1.CpLeaveApplyReq, username, deviceId string) {
|
||||
cpdailyInfo := cpdaily.GetInfo(ctx)
|
||||
leaveType := 0
|
||||
if req.LeaveType != nil {
|
||||
leaveType = *req.LeaveType
|
||||
}
|
||||
urgencyMobile := req.UrgencyMobile
|
||||
address := req.Address
|
||||
detailAddress := req.DetailAddress
|
||||
destination := ""
|
||||
if req.OutStatus {
|
||||
destination = address
|
||||
if detailAddress != "" {
|
||||
if destination != "" {
|
||||
destination += "/"
|
||||
}
|
||||
destination += detailAddress
|
||||
}
|
||||
} else {
|
||||
urgencyMobile = ""
|
||||
address = ""
|
||||
detailAddress = ""
|
||||
}
|
||||
|
||||
// 构造请假申请体
|
||||
applyBody := map[string]any{
|
||||
"leaveType": leaveType,
|
||||
"startTime": req.StartTime,
|
||||
"endTime": req.EndTime,
|
||||
"urgencyMobile": urgencyMobile,
|
||||
"leaveReason": req.LeaveReason,
|
||||
"isAttendance": nil,
|
||||
"outStatus": req.OutStatus,
|
||||
"address": address,
|
||||
"detailAddress": detailAddress,
|
||||
"destination": destination,
|
||||
"holidayAutoDefineVos": []any{},
|
||||
"appVersion": cpdailyInfo.AppVersion,
|
||||
"deviceId": deviceId,
|
||||
"ccInfo": map[string]any{"teachers": []any{}, "students": []any{}},
|
||||
"cheatType": nil,
|
||||
"source": 1,
|
||||
"location": map[string]any{
|
||||
"deviceId": deviceId,
|
||||
"systemName": "Android",
|
||||
"systemVersion": cpdailyInfo.SystemVersion,
|
||||
"model": "Redmi Note 8 Pro",
|
||||
"address": req.LocationAddr,
|
||||
"latitude": req.Latitude,
|
||||
"longitude": req.Longitude,
|
||||
},
|
||||
"locationType": 0,
|
||||
"disclaimers": "本人承诺填写的信息真实有效,并对本次提交请假申请的信息真实性负责。",
|
||||
"uaIsCpadaily": "true",
|
||||
"leaveApplyExplain": nil,
|
||||
}
|
||||
|
||||
// 审批人信息
|
||||
if req.ApproverWid != "" {
|
||||
applyBody["approverWid"] = req.ApproverWid
|
||||
applyBody["approverId"] = req.ApproverId
|
||||
applyBody["approverName"] = req.ApproverName
|
||||
}
|
||||
|
||||
// 图片附件
|
||||
if req.ImageUrl != "" {
|
||||
applyBody["applyAttach"] = []model.Attach{{Type: 1, Url: req.ImageUrl}}
|
||||
} else {
|
||||
applyBody["applyAttach"] = []any{}
|
||||
}
|
||||
|
||||
// 构造加密扩展头
|
||||
extensionForm := map[string]*gvar.Var{
|
||||
"lon": gvar.New(req.Longitude),
|
||||
"lat": gvar.New(req.Latitude),
|
||||
"model": gvar.New("Redmi Note 8 Pro"),
|
||||
"appVersion": gvar.New(cpdailyInfo.AppVersion),
|
||||
"systemVersion": gvar.New(cpdailyInfo.SystemVersion),
|
||||
"userId": gvar.New(username),
|
||||
"systemName": gvar.New("android"),
|
||||
"deviceId": gvar.New(deviceId),
|
||||
}
|
||||
extensionString := taskhelper.GetCpdailyExtension(ctx, extensionForm)
|
||||
|
||||
// 提交请假
|
||||
response, e := c.client.R().
|
||||
SetBody(applyBody).
|
||||
WithContext(ctx).
|
||||
SetHeader("Cpdaily-Extension", extensionString).
|
||||
Post(fmt.Sprintf("%s/wec-counselor-leave-apps/leave/apply", c.host))
|
||||
|
||||
utils.OkOrPanic(e)
|
||||
responseJSON := utils.ReadGjsonResult(response.Body)
|
||||
if responseJSON.Get("code").String() != "0" {
|
||||
utils.OkOrPanic(errors.NewBusinessError(503, responseJSON.Get("message").String()))
|
||||
}
|
||||
}
|
||||
@@ -146,8 +146,10 @@ func (r *NormalSubmitCaptchaResolver) getResultFromItStar(rawResponse json.RawMe
|
||||
utils.OkOrPanic(err)
|
||||
|
||||
url := r.getITStarUrl()
|
||||
apiKey := g.Config().MustGetWithEnv(gctx.GetInitCtx(), cst.ConfigCaptchaApiKey).String()
|
||||
|
||||
resp, err := r.client.R().
|
||||
SetHeader("X-Api-Key", apiKey).
|
||||
SetBody(responseMap).
|
||||
Post(url)
|
||||
utils.OkOrPanic(err)
|
||||
|
||||
@@ -226,20 +226,26 @@ func (c *CpdailyAutoSignTask) QueryFormByTitle(ctx context.Context, titleGlob st
|
||||
|
||||
// 尝试从已签到的历史任务中获取历史数据用于预填充
|
||||
signedMatch := findMatchTitle(signedTasks)
|
||||
if signedMatch != nil && (signedMatch["signInstanceWid"] == nil || matchTask["signInstanceWid"] == nil || signedMatch["signInstanceWid"].String() != matchTask["signInstanceWid"].String()) {
|
||||
histResp, histErr := c.client.R().
|
||||
SetBody(map[string]*gvar.Var{
|
||||
"taskName": gvar.New(signedMatch["taskName"].String()),
|
||||
"signWid": signedMatch["signWid"],
|
||||
"signInstanceWid": signedMatch["signInstanceWid"],
|
||||
}).
|
||||
WithContext(ctx).
|
||||
Post(c.getReqUrl("detailSignInstance"))
|
||||
if histErr == nil {
|
||||
histJSON := utils.ReadJson[map[string]*gvar.Var](histResp.Body)
|
||||
histDatas := histJSON["datas"].MapStrVar()
|
||||
if len(histDatas) > 0 {
|
||||
result["historyRows"] = gvar.New(histDatas)
|
||||
if signedMatch != nil {
|
||||
if matchTask != nil && signedMatch["signInstanceWid"] != nil && matchTask["signInstanceWid"] != nil &&
|
||||
signedMatch["signInstanceWid"].String() == matchTask["signInstanceWid"].String() {
|
||||
// 任务已签到(同一个 instanceWid),自身 rows 中的 longitude/latitude/position 就是用户自己提交的数据
|
||||
result["historyRows"] = gvar.New(formDatas)
|
||||
} else {
|
||||
histResp, histErr := c.client.R().
|
||||
SetBody(map[string]*gvar.Var{
|
||||
"taskName": gvar.New(signedMatch["taskName"].String()),
|
||||
"signWid": signedMatch["signWid"],
|
||||
"signInstanceWid": signedMatch["signInstanceWid"],
|
||||
}).
|
||||
WithContext(ctx).
|
||||
Post(c.getReqUrl("detailSignInstance"))
|
||||
if histErr == nil {
|
||||
histJSON := utils.ReadJson[map[string]*gvar.Var](histResp.Body)
|
||||
histDatas := histJSON["datas"].MapStrVar()
|
||||
if len(histDatas) > 0 {
|
||||
result["historyRows"] = gvar.New(histDatas)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,9 +442,11 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
|
||||
|
||||
// 优先从 historyRows(已签到的历史任务详情)中提取历史值,否则从 rows 中提取
|
||||
histRows := rows
|
||||
hasHistory := false
|
||||
if hr := rawJson["historyRows"]; hr != nil {
|
||||
if hrMap := hr.MapStrVar(); len(hrMap) > 0 {
|
||||
histRows = hrMap
|
||||
hasHistory = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,47 +458,51 @@ func (c *CpdailyAutoSignTask) ParseFormJson(ctx context.Context, rawJson map[str
|
||||
histSignPhotoUrl := ""
|
||||
histIsMalposition := ""
|
||||
|
||||
// 优先从 signPlaceSelected 提取经纬度和地址
|
||||
if sps := histRows["signPlaceSelected"]; sps != nil && sps.String() != "" && sps.String() != "[]" {
|
||||
placeArr := gjson.Parse(sps.String()).Array()
|
||||
if len(placeArr) > 0 {
|
||||
place := placeArr[0]
|
||||
lng := place.Get("longitude").String()
|
||||
lat := place.Get("latitude").String()
|
||||
addr := place.Get("address").String()
|
||||
if lng != "" && lat != "" {
|
||||
histLatAndLon = lng + "," + lat
|
||||
}
|
||||
if addr != "" {
|
||||
histAddress = addr
|
||||
}
|
||||
}
|
||||
}
|
||||
if histLatAndLon == "" {
|
||||
// 优先使用历史签到数据(真实地址),无历史时保底使用管理员配置的范围信息
|
||||
if hasHistory {
|
||||
// 从历史签到数据中提取用户真实的地址和经纬度
|
||||
if lon := histRows["longitude"]; lon != nil && lon.String() != "" {
|
||||
if lat := histRows["latitude"]; lat != nil && lat.String() != "" {
|
||||
histLatAndLon = lon.String() + "," + lat.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
if histAddress == "" {
|
||||
if pos := histRows["position"]; pos != nil && pos.String() != "" {
|
||||
histAddress = pos.String()
|
||||
if histAddress == "" {
|
||||
if pos := histRows["position"]; pos != nil && pos.String() != "" {
|
||||
histAddress = pos.String()
|
||||
}
|
||||
}
|
||||
if histAddress == "" {
|
||||
if addr := histRows["signAddress"]; addr != nil && addr.String() != "" {
|
||||
histAddress = addr.String()
|
||||
}
|
||||
}
|
||||
if photo := histRows["signPhotoUrl"]; photo != nil && photo.String() != "" {
|
||||
histSignPhotoUrl = photo.String()
|
||||
}
|
||||
if mal := histRows["isMalposition"]; mal != nil {
|
||||
if mal.Int() == 1 {
|
||||
histIsMalposition = "是"
|
||||
} else {
|
||||
histIsMalposition = "否"
|
||||
}
|
||||
}
|
||||
}
|
||||
if photo := histRows["signPhotoUrl"]; photo != nil && photo.String() != "" {
|
||||
histSignPhotoUrl = photo.String()
|
||||
}
|
||||
if mal := histRows["isMalposition"]; mal != nil {
|
||||
if mal.Int() == 1 {
|
||||
histIsMalposition = "是"
|
||||
} else {
|
||||
histIsMalposition = "否"
|
||||
// 无历史签到数据时,保底使用管理员配置的范围信息
|
||||
if histLatAndLon == "" && histAddress == "" {
|
||||
if sps := rows["signPlaceSelected"]; sps != nil && sps.String() != "" && sps.String() != "[]" {
|
||||
placeArr := gjson.Parse(sps.String()).Array()
|
||||
if len(placeArr) > 0 {
|
||||
place := placeArr[0]
|
||||
lng := place.Get("longitude").String()
|
||||
lat := place.Get("latitude").String()
|
||||
addr := place.Get("address").String()
|
||||
if lng != "" && lat != "" {
|
||||
histLatAndLon = lng + "," + lat
|
||||
}
|
||||
if addr != "" {
|
||||
histAddress = addr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -221,7 +221,6 @@ func (c *CpdailySleepCheckTask) QueryFormByTitle(ctx context.Context, titleGlob
|
||||
signedTasks := datas["signedTasks"].Array()
|
||||
|
||||
var matchTask map[string]*gvar.Var
|
||||
isFromSigned := false
|
||||
|
||||
matchTask = findMatchTitle(unSignedTasks)
|
||||
if matchTask == nil {
|
||||
@@ -229,9 +228,6 @@ func (c *CpdailySleepCheckTask) QueryFormByTitle(ctx context.Context, titleGlob
|
||||
|
||||
if matchTask == nil {
|
||||
matchTask = findMatchTitle(signedTasks)
|
||||
if matchTask != nil {
|
||||
isFromSigned = true
|
||||
}
|
||||
|
||||
if matchTask == nil {
|
||||
utils.OkOrPanic(errors.NewBusinessError(503, fmt.Sprintf("没有查询到查寝任务: %s", titleGlob)), func(err error) error {
|
||||
@@ -281,11 +277,15 @@ func (c *CpdailySleepCheckTask) QueryFormByTitle(ctx context.Context, titleGlob
|
||||
result["taskInfo"] = gvar.New(matchTask)
|
||||
result["rows"] = gvar.New(datas)
|
||||
|
||||
// 如果当前任务不是已签到的,尝试从已签到的历史任务中获取历史数据用于预填充
|
||||
if !isFromSigned {
|
||||
// 先看今天有没有已签到的同名任务
|
||||
signedMatch := findMatchTitle(signedTasks)
|
||||
if signedMatch != nil {
|
||||
// 尝试从已签到的历史任务中获取历史数据用于预填充
|
||||
// 无论当前任务是否已签到,都尝试获取历史数据
|
||||
signedMatch := findMatchTitle(signedTasks)
|
||||
if signedMatch != nil {
|
||||
if matchTask != nil && signedMatch["signInstanceWid"] != nil && matchTask["signInstanceWid"] != nil &&
|
||||
signedMatch["signInstanceWid"].String() == matchTask["signInstanceWid"].String() {
|
||||
// 任务已签到(同一个 instanceWid),自身 rows 中的 longitude/latitude/position 就是用户自己提交的数据
|
||||
result["historyRows"] = gvar.New(datas)
|
||||
} else {
|
||||
histResp, histErr := c.client.R().
|
||||
SetBody(map[string]*gvar.Var{
|
||||
"taskName": gvar.New(signedMatch["taskName"].String()),
|
||||
@@ -302,63 +302,63 @@ func (c *CpdailySleepCheckTask) QueryFormByTitle(ctx context.Context, titleGlob
|
||||
}
|
||||
}
|
||||
}
|
||||
// 今天没有已签到的同名任务,从历史月份中只查 signedTasks
|
||||
if result["historyRows"] == nil {
|
||||
func() {
|
||||
defer func() { recover() }()
|
||||
now := time.Now().In(cst.TimeZoneShangHai)
|
||||
year := strconv.Itoa(now.Year())
|
||||
month := strconv.Itoa(int(now.Month()))
|
||||
if len(month) == 1 {
|
||||
month = "0" + month
|
||||
}
|
||||
resp, e := c.client.R().
|
||||
SetBody(map[string]any{
|
||||
"statisticYearMonth": fmt.Sprintf("%s-%s", year, month),
|
||||
}).
|
||||
WithContext(ctx).
|
||||
Post(c.getReqUrl("getStuSignInfosByWeekMonth"))
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
respJSON := utils.ReadJson[map[string]*gvar.Var](resp.Body)
|
||||
monthDatas := respJSON["datas"].MapStrVar()
|
||||
monthRows := monthDatas["rows"].Vars()
|
||||
}
|
||||
// 今天没有已签到的同名任务,从历史月份中只查 signedTasks
|
||||
if result["historyRows"] == nil {
|
||||
func() {
|
||||
defer func() { recover() }()
|
||||
now := time.Now().In(cst.TimeZoneShangHai)
|
||||
year := strconv.Itoa(now.Year())
|
||||
month := strconv.Itoa(int(now.Month()))
|
||||
if len(month) == 1 {
|
||||
month = "0" + month
|
||||
}
|
||||
resp, e := c.client.R().
|
||||
SetBody(map[string]any{
|
||||
"statisticYearMonth": fmt.Sprintf("%s-%s", year, month),
|
||||
}).
|
||||
WithContext(ctx).
|
||||
Post(c.getReqUrl("getStuSignInfosByWeekMonth"))
|
||||
if e != nil {
|
||||
return
|
||||
}
|
||||
respJSON := utils.ReadJson[map[string]*gvar.Var](resp.Body)
|
||||
monthDatas := respJSON["datas"].MapStrVar()
|
||||
monthRows := monthDatas["rows"].Vars()
|
||||
|
||||
// 只从 signedTasks 中查找匹配的已签到任务
|
||||
for _, row := range monthRows {
|
||||
rowMap := row.MapStrVar()
|
||||
signed := rowMap["signedTasks"].Vars()
|
||||
for _, st := range signed {
|
||||
item := st.MapStrVar()
|
||||
tn, exist := item["taskName"]
|
||||
if !exist || tn.String() == "" {
|
||||
// 只从 signedTasks 中查找匹配的已签到任务
|
||||
for _, row := range monthRows {
|
||||
rowMap := row.MapStrVar()
|
||||
signed := rowMap["signedTasks"].Vars()
|
||||
for _, st := range signed {
|
||||
item := st.MapStrVar()
|
||||
tn, exist := item["taskName"]
|
||||
if !exist || tn.String() == "" {
|
||||
continue
|
||||
}
|
||||
if match.Match(tn.String(), titleGlob) {
|
||||
// 找到已签到的历史任务,获取详情
|
||||
dr, de := c.client.R().
|
||||
SetBody(map[string]*gvar.Var{
|
||||
"signInstanceWid": item["signInstanceWid"],
|
||||
"signWid": item["signWid"],
|
||||
"taskName": gvar.New(tn.String()),
|
||||
}).
|
||||
WithContext(ctx).
|
||||
Post(c.getReqUrl("detailSignInstance"))
|
||||
if de != nil {
|
||||
continue
|
||||
}
|
||||
if match.Match(tn.String(), titleGlob) {
|
||||
// 找到已签到的历史任务,获取详情
|
||||
dr, de := c.client.R().
|
||||
SetBody(map[string]*gvar.Var{
|
||||
"signInstanceWid": item["signInstanceWid"],
|
||||
"signWid": item["signWid"],
|
||||
"taskName": gvar.New(tn.String()),
|
||||
}).
|
||||
WithContext(ctx).
|
||||
Post(c.getReqUrl("detailSignInstance"))
|
||||
if de != nil {
|
||||
continue
|
||||
}
|
||||
dj := utils.ReadJson[map[string]*gvar.Var](dr.Body)
|
||||
dd := dj["datas"].MapStrVar()
|
||||
if len(dd) > 0 {
|
||||
result["historyRows"] = gvar.New(dd)
|
||||
return
|
||||
}
|
||||
dj := utils.ReadJson[map[string]*gvar.Var](dr.Body)
|
||||
dd := dj["datas"].MapStrVar()
|
||||
if len(dd) > 0 {
|
||||
result["historyRows"] = gvar.New(dd)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -518,9 +518,11 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
|
||||
|
||||
// 优先从 historyRows(已签到的历史任务详情)中提取历史值,否则从 rows 中提取
|
||||
histRows := rows
|
||||
hasHistory := false
|
||||
if hr := rawJson["historyRows"]; hr != nil {
|
||||
if hrMap := hr.MapStrVar(); len(hrMap) > 0 {
|
||||
histRows = hrMap
|
||||
hasHistory = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,48 +535,51 @@ func (c *CpdailySleepCheckTask) ParseFormJson(ctx context.Context, rawJson map[s
|
||||
histSignPhotoUrl := ""
|
||||
histIsMalposition := ""
|
||||
|
||||
// 优先从 signPlaceSelected 提取经纬度和地址
|
||||
if sps := histRows["signPlaceSelected"]; sps != nil && sps.String() != "" && sps.String() != "[]" {
|
||||
placeArr := gjson.Parse(sps.String()).Array()
|
||||
if len(placeArr) > 0 {
|
||||
place := placeArr[0]
|
||||
lng := place.Get("longitude").String()
|
||||
lat := place.Get("latitude").String()
|
||||
addr := place.Get("address").String()
|
||||
if lng != "" && lat != "" {
|
||||
histLatAndLon = lng + "," + lat
|
||||
}
|
||||
if addr != "" {
|
||||
histAddress = addr
|
||||
}
|
||||
}
|
||||
}
|
||||
// 降级:直接从 longitude/latitude/position 字段取
|
||||
if histLatAndLon == "" {
|
||||
// 优先使用历史签到数据(真实地址),无历史时保底使用管理员配置的范围信息
|
||||
if hasHistory {
|
||||
// 从历史签到数据中提取用户真实的地址和经纬度
|
||||
if lon := histRows["longitude"]; lon != nil && lon.String() != "" {
|
||||
if lat := histRows["latitude"]; lat != nil && lat.String() != "" {
|
||||
histLatAndLon = lon.String() + "," + lat.String()
|
||||
}
|
||||
}
|
||||
}
|
||||
if histAddress == "" {
|
||||
if pos := histRows["position"]; pos != nil && pos.String() != "" {
|
||||
histAddress = pos.String()
|
||||
if histAddress == "" {
|
||||
if pos := histRows["position"]; pos != nil && pos.String() != "" {
|
||||
histAddress = pos.String()
|
||||
}
|
||||
}
|
||||
if histAddress == "" {
|
||||
if addr := histRows["signAddress"]; addr != nil && addr.String() != "" {
|
||||
histAddress = addr.String()
|
||||
}
|
||||
}
|
||||
if photo := histRows["signPhotoUrl"]; photo != nil && photo.String() != "" {
|
||||
histSignPhotoUrl = photo.String()
|
||||
}
|
||||
if mal := histRows["isMalposition"]; mal != nil {
|
||||
if mal.Int() == 1 {
|
||||
histIsMalposition = "是"
|
||||
} else {
|
||||
histIsMalposition = "否"
|
||||
}
|
||||
}
|
||||
}
|
||||
if photo := histRows["signPhotoUrl"]; photo != nil && photo.String() != "" {
|
||||
histSignPhotoUrl = photo.String()
|
||||
}
|
||||
if mal := histRows["isMalposition"]; mal != nil {
|
||||
if mal.Int() == 1 {
|
||||
histIsMalposition = "是"
|
||||
} else {
|
||||
histIsMalposition = "否"
|
||||
// 无历史签到数据时,保底使用管理员配置的范围信息
|
||||
if histLatAndLon == "" && histAddress == "" {
|
||||
if sps := rows["signPlaceSelected"]; sps != nil && sps.String() != "" && sps.String() != "[]" {
|
||||
placeArr := gjson.Parse(sps.String()).Array()
|
||||
if len(placeArr) > 0 {
|
||||
place := placeArr[0]
|
||||
lng := place.Get("longitude").String()
|
||||
lat := place.Get("latitude").String()
|
||||
addr := place.Get("address").String()
|
||||
if lng != "" && lat != "" {
|
||||
histLatAndLon = lng + "," + lat
|
||||
}
|
||||
if addr != "" {
|
||||
histAddress = addr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,13 +166,18 @@ func (c *Course) Refresh(ctx context.Context, c2 model.Course, ch model.Chapter,
|
||||
}
|
||||
|
||||
func ParseJsUrl(str string) string {
|
||||
// <script defer src="https://cdnbasic.scbdc.edu.cn/%E7%9C%81%E7%BA%A7%E8%B5%84%E6%BA%90/hd/chunk-vendors.5e79f5ce155849.js">
|
||||
re := regexp.MustCompile(`https://[^"'\s]+chunk-vendors[^"'\s]+\.js`)
|
||||
match := re.FindString(str)
|
||||
if match == "" {
|
||||
// 网站已更新,chunk-vendors 不再存在。
|
||||
// 现在从 HTML 中找到任一 chunk-*.js 的 URL,提取其 base 路径和 hash,
|
||||
// 然后构造包含 src_views_home_index_vue 的组合 chunk 文件 URL。
|
||||
re := regexp.MustCompile(`(https://[^"'\s]+/)chunk-(?:libs|elementUI)\.([0-9a-f]+)\.js`)
|
||||
match := re.FindStringSubmatch(str)
|
||||
if len(match) < 3 {
|
||||
return ""
|
||||
}
|
||||
return strings.Replace(match, "chunk-vendors", "src_views_home_index_vue", 1)
|
||||
basePath := match[1]
|
||||
hash := match[2]
|
||||
// 用已知的组合 chunk 名称拼出 URL:该 chunk 同时包含首页和 CourseList 组件
|
||||
return basePath + "src_views_home_index_vue-src_views_noPermission_index_vue-src_views_components_CourseList_vue." + hash + ".js"
|
||||
}
|
||||
func (c *Course) GetCourseProcees(ctx context.Context, courseId string) (finished string, total string) {
|
||||
post, err := c.Client.R().SetHeader("X-Token", c.Token).SetBody(map[string]any{
|
||||
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
ConfigAuthorizationKey = "server.authorization"
|
||||
ConfigJwtKey = "server.jwt"
|
||||
ConfigCaptchaBaseUrl = "captcha.baseUrl"
|
||||
ConfigCaptchaApiKey = "captcha.apiKey"
|
||||
ConfigS3AccessKey = "s3.accessKey"
|
||||
ConfigS3SecretKey = "s3.secretKey"
|
||||
ConfigS3BaseUrl = "s3.baseUrl"
|
||||
@@ -28,21 +29,26 @@ const (
|
||||
|
||||
ConfigTaskConcurrency = "task.concurrency"
|
||||
|
||||
// Email配置常量
|
||||
ConfigEmailSMTPHost = "email.smtpHost"
|
||||
ConfigEmailSMTPPort = "email.smtpPort"
|
||||
ConfigEmailSMTPUsername = "email.smtpUsername"
|
||||
ConfigEmailSMTPPassword = "email.smtpPassword"
|
||||
ConfigEmailFromEmail = "email.fromEmail"
|
||||
ConfigEmailFromName = "email.fromName"
|
||||
ConfigEmailAuthMode = "email.authMode"
|
||||
ConfigEmailSMTPHost = "email.smtpHost"
|
||||
ConfigEmailSMTPPort = "email.smtpPort"
|
||||
ConfigEmailSMTPUsername = "email.smtpUsername"
|
||||
ConfigEmailSMTPPassword = "email.smtpPassword"
|
||||
ConfigEmailFromEmail = "email.fromEmail"
|
||||
ConfigEmailFromName = "email.fromName"
|
||||
ConfigEmailOAuthTenant = "email.oauthTenant"
|
||||
ConfigEmailOAuthClientID = "email.oauthClientId"
|
||||
ConfigEmailOAuthClientSecret = "email.oauthClientSecret"
|
||||
ConfigEmailOAuthRedirectURL = "email.oauthRedirectUrl"
|
||||
ConfigEmailOAuthRefreshToken = "email.oauthRefreshToken"
|
||||
ConfigEmailOAuthScope = "email.oauthScope"
|
||||
|
||||
// 基础积分配置
|
||||
ConfigBasePointCpdailyTask = "basePoint.cpdailyTask"
|
||||
ConfigBasePointCpdailyLeave = "basePoint.cpdailyLeave"
|
||||
ConfigBasePointCpdailyPhone = "basePoint.cpdailyPhone"
|
||||
|
||||
// 系统配置
|
||||
ConfigSystemAllowRegister = "system.allowRegister" // 是否允许注册
|
||||
ConfigSystemAllowRegister = "system.allowRegister"
|
||||
ConfigSystemRequireInviteCode = "system.requireInviteCode"
|
||||
|
||||
ConfigRedisCpdailyTask = "price.cpdaily_task"
|
||||
ConfigRedisCpdailyLeave = "price.cpdaily_leave"
|
||||
@@ -50,15 +56,13 @@ const (
|
||||
ConfigRedisXxt = "price.xxt"
|
||||
ConfigRedisSmarteduSc = "price.smartedu_sc"
|
||||
|
||||
// 第三方题库配置
|
||||
ConfigQuesBankUrl = "quesbank.url"
|
||||
ConfigQuesBankKey = "quesbank.key"
|
||||
)
|
||||
|
||||
const (
|
||||
// 应用程序常量
|
||||
AppName = "若梦系统"
|
||||
AppCopyright = "© 2025 若梦系统. 保留所有权利."
|
||||
AppCopyright = "© 2025 若梦系统. 保留所有权利"
|
||||
)
|
||||
|
||||
var (
|
||||
|
||||
@@ -7,6 +7,7 @@ const (
|
||||
RedisBalanceMigrate = "balancemigrate:" // 积分转移token
|
||||
RedisCpUserLogin = "cpuserlogin:"
|
||||
RedisCpUserAuthorize = "cpuser:authorize:"
|
||||
RedisCpUserQrCode = "cpuser:qrcode:" // 二维码UUID -> 系统用户ID
|
||||
RedisSchoolInfo = "cpuser:schoolinfo:"
|
||||
)
|
||||
|
||||
@@ -17,5 +18,6 @@ var (
|
||||
RedisBalanceMigrateExpire = int64(3600) // 积分转移链接1小时有效
|
||||
RedisCpUserLoginExpire = int64(180)
|
||||
RedisCpUserAuthorizeExpire = int64(1800)
|
||||
RedisCpUserQrCodeExpire = int64(300) // 二维码5分钟有效
|
||||
RedisSchoolInfoExpire = int64(3600 * 24 * 30) // 学校信息30天有效
|
||||
)
|
||||
|
||||
@@ -33,18 +33,22 @@ func InitDB() {
|
||||
Global = client
|
||||
dbDsn2, err := g.Cfg().Get(gctx.New(), cst.ConfigDatabaseDsn2)
|
||||
utils.OkOrPanic(err)
|
||||
GlobalGorm, err = gorm.Open(postgres.Open(dbDsn2.String()), &gorm.Config{})
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("连接pg数据库失败: %v", zap.Error(err))
|
||||
// PG 数据库为可选配置,如果 dsn2 为空则跳过
|
||||
if dbDsn2.String() != "" {
|
||||
GlobalGorm, err = gorm.Open(postgres.Open(dbDsn2.String()), &gorm.Config{})
|
||||
if err != nil {
|
||||
logger.Logger.Warn("连接pg数据库失败, 跳过: %v", zap.Error(err))
|
||||
} else {
|
||||
// 可选:配置连接池
|
||||
sqlDB, err := GlobalGorm.DB()
|
||||
if err != nil {
|
||||
logger.Logger.Warn("获取pg数据库实例失败, 跳过: %v", zap.Error(err))
|
||||
} else {
|
||||
// 设置连接池参数
|
||||
sqlDB.SetMaxIdleConns(10) // 设置空闲连接池中连接的最大数量
|
||||
sqlDB.SetMaxOpenConns(100) // 设置打开数据库连接的最大数量
|
||||
sqlDB.SetConnMaxLifetime(0) // 设置连接可复用的最大时间(0表示永久)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 可选:配置连接池
|
||||
sqlDB, err := GlobalGorm.DB()
|
||||
if err != nil {
|
||||
logger.Logger.Fatal("获取数据库实例失败: %v", zap.Error(err))
|
||||
}
|
||||
|
||||
// 设置连接池参数
|
||||
sqlDB.SetMaxIdleConns(10) // 设置空闲连接池中连接的最大数量
|
||||
sqlDB.SetMaxOpenConns(100) // 设置打开数据库连接的最大数量
|
||||
sqlDB.SetConnMaxLifetime(0) // 设置连接可复用的最大时间(0表示永久)
|
||||
}
|
||||
|
||||
@@ -2,14 +2,14 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
v1 "likedream-goframe/internal/api/v1"
|
||||
"likedream-goframe/internal/ent"
|
||||
"likedream-goframe/internal/service"
|
||||
"likedream-goframe/internal/utils"
|
||||
)
|
||||
|
||||
type ConfigCtrlV1 struct {
|
||||
}
|
||||
type ConfigCtrlV1 struct{}
|
||||
|
||||
func (c ConfigCtrlV1) Get(ctx context.Context, req *v1.ConfigGetReq) (res *utils.Response[*ent.SysConfig], err error) {
|
||||
defer utils.Pass(&err)
|
||||
@@ -31,13 +31,11 @@ func (c ConfigCtrlV1) Update(ctx context.Context, req *v1.ConfigUpdateReq) (res
|
||||
return service.Global.Config.Update(ctx, req), nil
|
||||
}
|
||||
|
||||
// GetPriceConfig 获取费率配置
|
||||
func (c ConfigCtrlV1) GetPriceConfig(ctx context.Context, req *v1.PriceConfigGetReq) (res *utils.Response[*v1.PriceConfigResp], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.Config.GetPriceConfig(ctx, req), nil
|
||||
}
|
||||
|
||||
// UpdatePriceConfig 更新费率配置
|
||||
func (c ConfigCtrlV1) UpdatePriceConfig(ctx context.Context, req *v1.PriceConfigUpdateReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.Config.UpdatePriceConfig(ctx, req), nil
|
||||
@@ -46,3 +44,23 @@ func (c ConfigCtrlV1) UpdatePriceConfig(ctx context.Context, req *v1.PriceConfig
|
||||
func NewConfigCtrlV1() IConfigCtrlV1 {
|
||||
return &ConfigCtrlV1{}
|
||||
}
|
||||
|
||||
func (c ConfigCtrlV1) GetSmtpConfig(ctx context.Context, req *v1.SmtpConfigGetReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.Config.GetSmtpConfig(ctx, req), nil
|
||||
}
|
||||
|
||||
func (c ConfigCtrlV1) UpdateSmtpConfig(ctx context.Context, req *v1.SmtpConfigUpdateReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.Config.UpdateSmtpConfig(ctx, req), nil
|
||||
}
|
||||
|
||||
func (c ConfigCtrlV1) TestSmtp(ctx context.Context, req *v1.SmtpTestReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.Config.TestSmtp(ctx, req), nil
|
||||
}
|
||||
|
||||
func (c ConfigCtrlV1) GetOutlookOAuthAuthorizeURL(ctx context.Context, req *v1.OutlookOAuthAuthorizeURLReq) (res *utils.Response[*v1.OutlookOAuthAuthorizeURLResp], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.Config.GetOutlookOAuthAuthorizeURL(ctx, req), nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
v1 "likedream-goframe/internal/api/v1"
|
||||
"likedream-goframe/internal/ent"
|
||||
"likedream-goframe/internal/utils"
|
||||
@@ -14,4 +15,8 @@ type IConfigCtrlV1 interface {
|
||||
Update(ctx context.Context, req *v1.ConfigUpdateReq) (*utils.Response[any], error)
|
||||
GetPriceConfig(ctx context.Context, req *v1.PriceConfigGetReq) (*utils.Response[*v1.PriceConfigResp], error)
|
||||
UpdatePriceConfig(ctx context.Context, req *v1.PriceConfigUpdateReq) (*utils.Response[any], error)
|
||||
GetSmtpConfig(ctx context.Context, req *v1.SmtpConfigGetReq) (*utils.Response[any], error)
|
||||
UpdateSmtpConfig(ctx context.Context, req *v1.SmtpConfigUpdateReq) (*utils.Response[any], error)
|
||||
TestSmtp(ctx context.Context, req *v1.SmtpTestReq) (*utils.Response[any], error)
|
||||
GetOutlookOAuthAuthorizeURL(ctx context.Context, req *v1.OutlookOAuthAuthorizeURLReq) (*utils.Response[*v1.OutlookOAuthAuthorizeURLResp], error)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
v1 "likedream-goframe/internal/api/v1"
|
||||
mailservice "likedream-goframe/internal/service/email"
|
||||
"likedream-goframe/internal/utils"
|
||||
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
)
|
||||
|
||||
type MailOAuthCtrlV1 struct{}
|
||||
|
||||
func NewMailOAuthCtrlV1() *MailOAuthCtrlV1 {
|
||||
return &MailOAuthCtrlV1{}
|
||||
}
|
||||
|
||||
func (c *MailOAuthCtrlV1) OutlookCallback(ctx context.Context, req *v1.OutlookOAuthCallbackReq) (res *utils.Response[any], err error) {
|
||||
r := ghttp.RequestFromCtx(ctx)
|
||||
r.Response.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
if req.Error != "" {
|
||||
r.Response.Write(mailservice.BuildOutlookOAuthErrorHTML(fmt.Sprintf("%s: %s", req.Error, req.ErrorDescription)))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
refreshToken, frontendOrigin, exchangeErr := mailservice.NewV1().ExchangeOutlookAuthorizeCode(ctx, req.State, req.Code)
|
||||
if exchangeErr != nil {
|
||||
r.Response.Write(mailservice.BuildOutlookOAuthPopupHTML("*", false, exchangeErr.Error(), ""))
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
r.Response.Write(mailservice.BuildOutlookOAuthPopupHTML(frontendOrigin, true, "授权成功", refreshToken))
|
||||
return nil, nil
|
||||
}
|
||||
@@ -31,6 +31,31 @@ func (c CpLeaveCtrlV1) Upload(ctx context.Context, req *v1.CpLeaveUploadReq) (re
|
||||
return service.Global.CpLeave.Upload(ctx, req), nil
|
||||
}
|
||||
|
||||
func (c CpLeaveCtrlV1) CheckLeave(ctx context.Context, req *v1.CpLeaveCheckReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.CpLeave.CheckLeave(ctx, req), nil
|
||||
}
|
||||
|
||||
func (c CpLeaveCtrlV1) GetLeaveTypes(ctx context.Context, req *v1.CpLeaveTypesReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.CpLeave.GetLeaveTypes(ctx, req), nil
|
||||
}
|
||||
|
||||
func (c CpLeaveCtrlV1) GetLeaveTypeCfg(ctx context.Context, req *v1.CpLeaveTypeCfgReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.CpLeave.GetLeaveTypeCfg(ctx, req), nil
|
||||
}
|
||||
|
||||
func (c CpLeaveCtrlV1) GetApproveFlow(ctx context.Context, req *v1.CpLeaveApproveFlowReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.CpLeave.GetApproveFlow(ctx, req), nil
|
||||
}
|
||||
|
||||
func (c CpLeaveCtrlV1) Apply(ctx context.Context, req *v1.CpLeaveApplyReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.CpLeave.Apply(ctx, req), nil
|
||||
}
|
||||
|
||||
func NewCpLeaveCtrlV1() ICpLeaveCtrlV1 {
|
||||
return &CpLeaveCtrlV1{}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ func (c CpTaskCtrlV1) Delete(ctx context.Context, req *v1.DeleteTaskReq) (res *u
|
||||
return service.Global.CpTask.Delete(ctx, req), nil
|
||||
}
|
||||
|
||||
func (c CpTaskCtrlV1) UpdateTaskTitle(ctx context.Context, req *v1.UpdateTaskTitleReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.CpTask.UpdateTaskTitle(ctx, req), nil
|
||||
}
|
||||
|
||||
func (c CpTaskCtrlV1) ExecuteNow(ctx context.Context, req *v1.ExecuteNowReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.CpTask.ExecuteNow(ctx, req), nil
|
||||
|
||||
@@ -101,3 +101,13 @@ func (c CpUserCtrlV1) AddByCode(ctx context.Context, req *v1.AddByCode) (res *ut
|
||||
}
|
||||
return service.Global.CpUser.AddByCode(ctx, req), nil
|
||||
}
|
||||
|
||||
func (c CpUserCtrlV1) GenerateQr(ctx context.Context, req *v1.CpUserGenerateQrReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.CpUser.GenerateQr(ctx, req), nil
|
||||
}
|
||||
|
||||
func (c CpUserCtrlV1) CheckQr(ctx context.Context, req *v1.CpUserCheckQrReq) (res *utils.Response[any], err error) {
|
||||
defer utils.Pass(&err)
|
||||
return service.Global.CpUser.CheckQr(ctx, req), nil
|
||||
}
|
||||
|
||||
@@ -12,4 +12,9 @@ type ICpLeaveCtrlV1 interface {
|
||||
List(ctx context.Context, req *v1.CpLeaveListReq) (*utils.Response[[]*ent.CpLeave], error)
|
||||
Report(ctx context.Context, req *v1.CpLeaveReportReq) (*utils.Response[any], error)
|
||||
Upload(ctx context.Context, req *v1.CpLeaveUploadReq) (*utils.Response[string], error)
|
||||
CheckLeave(ctx context.Context, req *v1.CpLeaveCheckReq) (*utils.Response[any], error)
|
||||
GetLeaveTypes(ctx context.Context, req *v1.CpLeaveTypesReq) (*utils.Response[any], error)
|
||||
GetLeaveTypeCfg(ctx context.Context, req *v1.CpLeaveTypeCfgReq) (*utils.Response[any], error)
|
||||
GetApproveFlow(ctx context.Context, req *v1.CpLeaveApproveFlowReq) (*utils.Response[any], error)
|
||||
Apply(ctx context.Context, req *v1.CpLeaveApplyReq) (*utils.Response[any], error)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type ICpTaskCtrlV1 interface {
|
||||
ExecuteNow(ctx context.Context, req *v1.ExecuteNowReq) (res *utils.Response[any], err error)
|
||||
PauseTask(ctx context.Context, req *v1.PauseTaskReq) (res *utils.Response[any], err error)
|
||||
FindLocation(ctx context.Context, req *v1.FindLocationReq) (res *utils.Response[any], err error)
|
||||
UpdateTaskTitle(ctx context.Context, req *v1.UpdateTaskTitleReq) (res *utils.Response[any], err error)
|
||||
GetTaskStatistics(ctx context.Context, req *v1.GetTaskStatisticsReq) (res *utils.Response[[]*v1.TaskStatisticsItem], err error)
|
||||
GetTaskStatisticsV2(ctx context.Context, req *v1.GetTaskStatisticsV2Req) (res *utils.Response[[]*v1.TaskStatisticsV2Item], err error)
|
||||
GetDashboardSummary(ctx context.Context, req *v1.GetDashboardSummaryReq) (res *utils.Response[*v1.DashboardSummary], err error)
|
||||
|
||||
@@ -31,4 +31,8 @@ type ICpUserCtrlV1 interface {
|
||||
// 手机登录获取验证码
|
||||
LoginGetCode(ctx context.Context, req *v1.LoginGetCode) (*utils.Response[any], error)
|
||||
AddByCode(ctx context.Context, req *v1.AddByCode) (*utils.Response[any], error)
|
||||
|
||||
// 二维码获取设备码
|
||||
GenerateQr(ctx context.Context, req *v1.CpUserGenerateQrReq) (*utils.Response[any], error)
|
||||
CheckQr(ctx context.Context, req *v1.CpUserCheckQrReq) (*utils.Response[any], error)
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ type Type string
|
||||
// Type values.
|
||||
const (
|
||||
TypeReport Type = "report"
|
||||
TypeApply Type = "apply"
|
||||
)
|
||||
|
||||
func (_type Type) String() string {
|
||||
@@ -135,7 +136,7 @@ func (_type Type) String() string {
|
||||
// TypeValidator is a validator for the "type" field enum values. It is called by the builders before save.
|
||||
func TypeValidator(_type Type) error {
|
||||
switch _type {
|
||||
case TypeReport:
|
||||
case TypeReport, TypeApply:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("cpleave: invalid enum value for type field: %q", _type)
|
||||
|
||||
@@ -77,7 +77,7 @@ var (
|
||||
{Name: "leave_id", Type: field.TypeString},
|
||||
{Name: "title", Type: field.TypeString},
|
||||
{Name: "status", Type: field.TypeEnum, Enums: []string{"done", "fail"}},
|
||||
{Name: "type", Type: field.TypeEnum, Enums: []string{"report"}},
|
||||
{Name: "type", Type: field.TypeEnum, Enums: []string{"report", "apply"}},
|
||||
{Name: "desc", Type: field.TypeString, Nullable: true, Size: 2147483647},
|
||||
{Name: "cp_user_id", Type: field.TypeInt64},
|
||||
{Name: "user_id", Type: field.TypeInt64},
|
||||
|
||||
@@ -21,7 +21,7 @@ func (CpLeave) Fields() []ent.Field {
|
||||
field.String("leave_id").NotEmpty(),
|
||||
field.String("title").NotEmpty(),
|
||||
field.Enum("status").Values("done", "fail"),
|
||||
field.Enum("type").Values("report"),
|
||||
field.Enum("type").Values("report", "apply"),
|
||||
field.Text("desc").Optional(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
package email
|
||||
|
||||
type SendPayload struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
FromName string
|
||||
FromEmail string
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
|
||||
AuthMode string
|
||||
|
||||
FromName string
|
||||
FromEmail string
|
||||
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
|
||||
OAuthTenant string
|
||||
OAuthClientID string
|
||||
OAuthClientSecret string
|
||||
OAuthRedirectURL string
|
||||
OAuthRefreshToken string
|
||||
OAuthScope string
|
||||
}
|
||||
|
||||
@@ -4,58 +4,41 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/gob"
|
||||
|
||||
"likedream-goframe/internal/framework/logger"
|
||||
"likedream-goframe/internal/framework/model/email"
|
||||
payloadmodel "likedream-goframe/internal/framework/model/email"
|
||||
mailservice "likedream-goframe/internal/service/email"
|
||||
"likedream-goframe/internal/utils"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/samber/oops"
|
||||
"github.com/wneessen/go-mail"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type EmailTaskHandler struct {
|
||||
}
|
||||
type EmailTaskHandler struct{}
|
||||
|
||||
func (h *EmailTaskHandler) ProcessTask(ctx context.Context, task *asynq.Task) (err error) {
|
||||
defer utils.Pass(&err)
|
||||
var payload email.SendPayload
|
||||
e := gob.NewDecoder(bytes.NewReader(task.Payload())).Decode(&payload)
|
||||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||||
logger.Logger.Info("开始处理邮件发送", zap.String("邮箱", payload.To))
|
||||
|
||||
var payload payloadmodel.SendPayload
|
||||
if e := gob.NewDecoder(bytes.NewReader(task.Payload())).Decode(&payload); e != nil {
|
||||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||||
}
|
||||
|
||||
logger.Logger.Info("start sending email", zap.String("to", payload.To))
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// 打印日志
|
||||
logger.Logger.Error("邮件发送失败", zap.String("邮箱", payload.To), zap.Error(r.(error)))
|
||||
// 重新抛出相同的 panic
|
||||
logger.Logger.Error("send email failed", zap.String("to", payload.To), zap.Error(r.(error)))
|
||||
panic(r)
|
||||
}
|
||||
}()
|
||||
|
||||
message := mail.NewMsg()
|
||||
svc := mailservice.NewV1()
|
||||
cfg := mailservice.ConfigFromPayload(payload)
|
||||
if e := svc.SendMessageNow(ctx, cfg, payload.To, payload.Subject, payload.Body); e != nil {
|
||||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||||
}
|
||||
|
||||
e = message.FromFormat(payload.FromName, payload.FromEmail)
|
||||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||||
|
||||
e = message.AddTo(payload.To)
|
||||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||||
|
||||
message.Subject(payload.Subject)
|
||||
|
||||
message.SetBodyString(mail.TypeTextHTML, payload.Body)
|
||||
|
||||
client, e := mail.NewClient(payload.SMTPHost, mail.WithPort(payload.SMTPPort),
|
||||
mail.WithSMTPAuth(mail.SMTPAuthLogin), // Outlook 使用 LOGIN 认证
|
||||
mail.WithTLSPortPolicy(mail.TLSMandatory),
|
||||
mail.WithTimeout(30*time.Second), // 连接超时 30 秒
|
||||
mail.WithUsername(payload.SMTPUsername), mail.WithPassword(payload.SMTPPassword))
|
||||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||||
|
||||
defer utils.LogError(ctx, "关闭邮件客户端失败", client.Close())
|
||||
|
||||
e = client.DialAndSendWithContext(ctx, message)
|
||||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||||
logger.Logger.Info("邮件发送完成", zap.String("邮箱", payload.To))
|
||||
return
|
||||
logger.Logger.Info("email sent", zap.String("to", payload.To))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,41 +2,39 @@ package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
v1 "likedream-goframe/internal/api/v1"
|
||||
cst2 "likedream-goframe/internal/common/cst"
|
||||
"likedream-goframe/internal/common/db"
|
||||
errors2 "likedream-goframe/internal/common/errors"
|
||||
"likedream-goframe/internal/ent"
|
||||
"likedream-goframe/internal/ent/sysconfig"
|
||||
mailservice "likedream-goframe/internal/service/email"
|
||||
"likedream-goframe/internal/utils"
|
||||
)
|
||||
|
||||
type ConfigServiceV1 struct {
|
||||
}
|
||||
type ConfigServiceV1 struct{}
|
||||
|
||||
func NewV1() *ConfigServiceV1 {
|
||||
return &ConfigServiceV1{}
|
||||
}
|
||||
|
||||
// Get 获取配置
|
||||
func (s *ConfigServiceV1) Get(ctx context.Context, req *v1.ConfigGetReq) *utils.Response[*ent.SysConfig] {
|
||||
config, err := db.Global.SysConfig.Query().
|
||||
Where(sysconfig.KeyEQ(req.Key)).
|
||||
Only(ctx)
|
||||
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(404, "配置不存在"))
|
||||
}
|
||||
utils.OkOrPanic(err)
|
||||
}
|
||||
|
||||
return utils.SuccessWithData[*ent.SysConfig](config)
|
||||
}
|
||||
|
||||
// PublicGet 获取公开配置(无需认证)
|
||||
func (s *ConfigServiceV1) PublicGet(ctx context.Context, req *v1.ConfigPublicGetReq) *utils.Response[*ent.SysConfig] {
|
||||
// 只允许获取特定的公开配置
|
||||
allowedKeys := []string{"system.announcement", "system.allowRegister"}
|
||||
allowedKeys := []string{"system.announcement", "system.allowRegister", "system.requireInviteCode"}
|
||||
|
||||
allowed := false
|
||||
for _, key := range allowedKeys {
|
||||
@@ -45,7 +43,6 @@ func (s *ConfigServiceV1) PublicGet(ctx context.Context, req *v1.ConfigPublicGet
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(403, "该配置不允许公开访问"))
|
||||
}
|
||||
@@ -53,10 +50,8 @@ func (s *ConfigServiceV1) PublicGet(ctx context.Context, req *v1.ConfigPublicGet
|
||||
config, err := db.Global.SysConfig.Query().
|
||||
Where(sysconfig.KeyEQ(req.Key)).
|
||||
Only(ctx)
|
||||
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
// 如果配置不存在,返回空值
|
||||
return utils.SuccessWithData[*ent.SysConfig](nil)
|
||||
}
|
||||
utils.OkOrPanic(err)
|
||||
@@ -65,7 +60,6 @@ func (s *ConfigServiceV1) PublicGet(ctx context.Context, req *v1.ConfigPublicGet
|
||||
return utils.SuccessWithData[*ent.SysConfig](config)
|
||||
}
|
||||
|
||||
// List 获取配置列表
|
||||
func (s *ConfigServiceV1) List(ctx context.Context, req *v1.ConfigListReq) *utils.Response[[]*ent.SysConfig] {
|
||||
query := db.Global.SysConfig.Query().
|
||||
Order(ent.Asc(sysconfig.FieldKey))
|
||||
@@ -83,37 +77,30 @@ func (s *ConfigServiceV1) List(ctx context.Context, req *v1.ConfigListReq) *util
|
||||
return utils.SuccessWithTotal[[]*ent.SysConfig](data, total)
|
||||
}
|
||||
|
||||
// Update 更新配置
|
||||
func (s *ConfigServiceV1) Update(ctx context.Context, req *v1.ConfigUpdateReq) *utils.Response[any] {
|
||||
// 检查配置是否存在
|
||||
config, err := db.Global.SysConfig.Query().
|
||||
Where(sysconfig.KeyEQ(req.Key)).
|
||||
Only(ctx)
|
||||
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
// 如果不存在,创建新配置
|
||||
db.Global.SysConfig.Create().
|
||||
SetKey(req.Key).
|
||||
SetValue(req.Value).
|
||||
SetDescription(req.Description).
|
||||
SetType("markdown"). // 默认为 markdown 类型
|
||||
SetType("markdown").
|
||||
SaveX(ctx)
|
||||
return utils.SuccessWithMsg("配置创建成功")
|
||||
}
|
||||
utils.OkOrPanic(err)
|
||||
}
|
||||
|
||||
// 更新配置
|
||||
db.Global.SysConfig.UpdateOne(config).
|
||||
SetValue(req.Value).
|
||||
SetDescription(req.Description).
|
||||
ExecX(ctx)
|
||||
|
||||
return utils.SuccessWithMsg("配置更新成功")
|
||||
}
|
||||
|
||||
// 费率配置键常量
|
||||
const (
|
||||
PriceKeyCpdailyTask = "price.cpdaily_task"
|
||||
PriceKeyCpdailyLeave = "price.cpdaily_leave"
|
||||
@@ -122,7 +109,6 @@ const (
|
||||
PriceKeySmarteduSc = "price.smartedu_sc"
|
||||
)
|
||||
|
||||
// GetPriceConfig 获取费率配置
|
||||
func (s *ConfigServiceV1) GetPriceConfig(ctx context.Context, req *v1.PriceConfigGetReq) *utils.Response[*v1.PriceConfigResp] {
|
||||
resp := &v1.PriceConfigResp{
|
||||
CpdailyTask: s.getConfigValue(ctx, PriceKeyCpdailyTask, "0.5"),
|
||||
@@ -134,7 +120,6 @@ func (s *ConfigServiceV1) GetPriceConfig(ctx context.Context, req *v1.PriceConfi
|
||||
return utils.SuccessWithData[*v1.PriceConfigResp](resp)
|
||||
}
|
||||
|
||||
// getConfigValue 获取配置值,不存在则返回默认值
|
||||
func (s *ConfigServiceV1) getConfigValue(ctx context.Context, key, defaultValue string) string {
|
||||
config, err := db.Global.SysConfig.Query().
|
||||
Where(sysconfig.KeyEQ(key)).
|
||||
@@ -145,9 +130,7 @@ func (s *ConfigServiceV1) getConfigValue(ctx context.Context, key, defaultValue
|
||||
return config.Value
|
||||
}
|
||||
|
||||
// UpdatePriceConfig 更新费率配置
|
||||
func (s *ConfigServiceV1) UpdatePriceConfig(ctx context.Context, req *v1.PriceConfigUpdateReq) *utils.Response[any] {
|
||||
// 更新各项费率配置
|
||||
if req.CpdailyTask != "" {
|
||||
s.upsertConfig(ctx, PriceKeyCpdailyTask, req.CpdailyTask, "今日校园签到单价")
|
||||
}
|
||||
@@ -166,12 +149,10 @@ func (s *ConfigServiceV1) UpdatePriceConfig(ctx context.Context, req *v1.PriceCo
|
||||
return utils.SuccessWithMsg("费率配置更新成功")
|
||||
}
|
||||
|
||||
// upsertConfig 更新或创建配置
|
||||
func (s *ConfigServiceV1) upsertConfig(ctx context.Context, key, value, description string) {
|
||||
config, err := db.Global.SysConfig.Query().
|
||||
Where(sysconfig.KeyEQ(key)).
|
||||
Only(ctx)
|
||||
|
||||
if err != nil {
|
||||
if ent.IsNotFound(err) {
|
||||
db.Global.SysConfig.Create().
|
||||
@@ -189,3 +170,155 @@ func (s *ConfigServiceV1) upsertConfig(ctx context.Context, key, value, descript
|
||||
SetValue(value).
|
||||
ExecX(ctx)
|
||||
}
|
||||
|
||||
func (s *ConfigServiceV1) GetSmtpConfig(ctx context.Context, req *v1.SmtpConfigGetReq) *utils.Response[any] {
|
||||
keys := []string{
|
||||
cst2.ConfigEmailAuthMode,
|
||||
cst2.ConfigEmailSMTPHost,
|
||||
cst2.ConfigEmailSMTPPort,
|
||||
cst2.ConfigEmailSMTPUsername,
|
||||
cst2.ConfigEmailSMTPPassword,
|
||||
cst2.ConfigEmailFromEmail,
|
||||
cst2.ConfigEmailFromName,
|
||||
cst2.ConfigEmailOAuthTenant,
|
||||
cst2.ConfigEmailOAuthClientID,
|
||||
cst2.ConfigEmailOAuthClientSecret,
|
||||
cst2.ConfigEmailOAuthRedirectURL,
|
||||
cst2.ConfigEmailOAuthRefreshToken,
|
||||
cst2.ConfigEmailOAuthScope,
|
||||
}
|
||||
|
||||
result := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
config, err := db.Global.SysConfig.Query().Where(sysconfig.KeyEQ(key)).Only(ctx)
|
||||
if err == nil {
|
||||
result[key] = config.Value
|
||||
}
|
||||
}
|
||||
|
||||
cfg := mailservice.NormalizeConfig(mailservice.EmailConfig{
|
||||
AuthMode: result[cst2.ConfigEmailAuthMode],
|
||||
SMTPHost: result[cst2.ConfigEmailSMTPHost],
|
||||
SMTPPort: atoiOrZero(result[cst2.ConfigEmailSMTPPort]),
|
||||
SMTPUsername: result[cst2.ConfigEmailSMTPUsername],
|
||||
SMTPPassword: result[cst2.ConfigEmailSMTPPassword],
|
||||
FromEmail: result[cst2.ConfigEmailFromEmail],
|
||||
FromName: result[cst2.ConfigEmailFromName],
|
||||
OAuthTenant: result[cst2.ConfigEmailOAuthTenant],
|
||||
OAuthClientID: result[cst2.ConfigEmailOAuthClientID],
|
||||
OAuthClientSecret: result[cst2.ConfigEmailOAuthClientSecret],
|
||||
OAuthRedirectURL: result[cst2.ConfigEmailOAuthRedirectURL],
|
||||
OAuthRefreshToken: result[cst2.ConfigEmailOAuthRefreshToken],
|
||||
OAuthScope: result[cst2.ConfigEmailOAuthScope],
|
||||
})
|
||||
|
||||
return utils.SuccessWithData[any](map[string]any{
|
||||
"authMode": cfg.AuthMode,
|
||||
"smtpHost": cfg.SMTPHost,
|
||||
"smtpPort": cfg.SMTPPort,
|
||||
"smtpUsername": cfg.SMTPUsername,
|
||||
"smtpPassword": cfg.SMTPPassword,
|
||||
"fromEmail": cfg.FromEmail,
|
||||
"fromName": cfg.FromName,
|
||||
"oauthTenant": cfg.OAuthTenant,
|
||||
"oauthClientId": cfg.OAuthClientID,
|
||||
"oauthClientSecret": cfg.OAuthClientSecret,
|
||||
"oauthRedirectUrl": cfg.OAuthRedirectURL,
|
||||
"oauthRefreshToken": cfg.OAuthRefreshToken,
|
||||
"oauthScope": cfg.OAuthScope,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *ConfigServiceV1) UpdateSmtpConfig(ctx context.Context, req *v1.SmtpConfigUpdateReq) *utils.Response[any] {
|
||||
cfg := mailservice.NormalizeConfig(mailservice.EmailConfig{
|
||||
AuthMode: req.AuthMode,
|
||||
SMTPHost: req.SmtpHost,
|
||||
SMTPPort: req.SmtpPort,
|
||||
SMTPUsername: req.SmtpUsername,
|
||||
SMTPPassword: req.SmtpPassword,
|
||||
FromEmail: req.FromEmail,
|
||||
FromName: req.FromName,
|
||||
OAuthTenant: req.OAuthTenant,
|
||||
OAuthClientID: req.OAuthClientID,
|
||||
OAuthClientSecret: req.OAuthClientSecret,
|
||||
OAuthRedirectURL: req.OAuthRedirectURL,
|
||||
OAuthRefreshToken: req.OAuthRefreshToken,
|
||||
OAuthScope: req.OAuthScope,
|
||||
})
|
||||
if err := mailservice.NewV1().ValidateConfig(cfg); err != nil {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(400, err.Error()))
|
||||
}
|
||||
|
||||
configs := map[string]string{
|
||||
cst2.ConfigEmailAuthMode: cfg.AuthMode,
|
||||
cst2.ConfigEmailSMTPHost: cfg.SMTPHost,
|
||||
cst2.ConfigEmailSMTPPort: fmt.Sprintf("%d", cfg.SMTPPort),
|
||||
cst2.ConfigEmailSMTPUsername: cfg.SMTPUsername,
|
||||
cst2.ConfigEmailSMTPPassword: cfg.SMTPPassword,
|
||||
cst2.ConfigEmailFromEmail: cfg.FromEmail,
|
||||
cst2.ConfigEmailFromName: cfg.FromName,
|
||||
cst2.ConfigEmailOAuthTenant: cfg.OAuthTenant,
|
||||
cst2.ConfigEmailOAuthClientID: cfg.OAuthClientID,
|
||||
cst2.ConfigEmailOAuthClientSecret: cfg.OAuthClientSecret,
|
||||
cst2.ConfigEmailOAuthRedirectURL: cfg.OAuthRedirectURL,
|
||||
cst2.ConfigEmailOAuthRefreshToken: cfg.OAuthRefreshToken,
|
||||
cst2.ConfigEmailOAuthScope: cfg.OAuthScope,
|
||||
}
|
||||
|
||||
for key, value := range configs {
|
||||
exists, _ := db.Global.SysConfig.Query().Where(sysconfig.KeyEQ(key)).Exist(ctx)
|
||||
if exists {
|
||||
db.Global.SysConfig.Update().Where(sysconfig.KeyEQ(key)).SetValue(value).ExecX(ctx)
|
||||
} else {
|
||||
db.Global.SysConfig.Create().
|
||||
SetKey(key).
|
||||
SetValue(value).
|
||||
SetDescription("邮件配置").
|
||||
ExecX(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
return utils.SuccessWithMsg("邮件配置保存成功")
|
||||
}
|
||||
|
||||
func (s *ConfigServiceV1) TestSmtp(ctx context.Context, req *v1.SmtpTestReq) *utils.Response[any] {
|
||||
emailService := mailservice.NewV1()
|
||||
cfg := emailService.GetConfig()
|
||||
if err := emailService.ValidateConfig(cfg); err != nil {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(400, err.Error()))
|
||||
}
|
||||
|
||||
subject := "邮件配置测试"
|
||||
body := "<h2>邮件配置测试成功</h2><p>如果您收到此邮件,说明当前邮件配置可以正常发信。</p>"
|
||||
if err := emailService.SendMessageNow(ctx, cfg, req.ToEmail, subject, body); err != nil {
|
||||
utils.OkOrPanic(err)
|
||||
}
|
||||
return utils.SuccessWithMsg("测试邮件发送成功")
|
||||
}
|
||||
|
||||
func (s *ConfigServiceV1) GetOutlookOAuthAuthorizeURL(ctx context.Context, req *v1.OutlookOAuthAuthorizeURLReq) *utils.Response[*v1.OutlookOAuthAuthorizeURLResp] {
|
||||
cfg := mailservice.NormalizeConfig(mailservice.EmailConfig{
|
||||
AuthMode: mailservice.AuthModeOutlookOAuth2,
|
||||
FromEmail: req.FromEmail,
|
||||
FromName: "Microsoft Graph OAuth2",
|
||||
OAuthTenant: req.OAuthTenant,
|
||||
OAuthClientID: req.OAuthClientID,
|
||||
OAuthClientSecret: req.OAuthClientSecret,
|
||||
OAuthScope: req.OAuthScope,
|
||||
})
|
||||
|
||||
resp, err := mailservice.NewV1().BuildOutlookAuthorizeURL(cfg, req.CallbackBaseURL, req.FrontendOrigin)
|
||||
if err != nil {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(400, err.Error()))
|
||||
}
|
||||
return utils.SuccessWithData(resp)
|
||||
}
|
||||
|
||||
func atoiOrZero(value string) int {
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
var result int
|
||||
_, _ = fmt.Sscanf(value, "%d", &result)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"likedream-goframe/internal/ent/sysuser"
|
||||
"likedream-goframe/internal/service/balance"
|
||||
"likedream-goframe/internal/utils"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
@@ -24,6 +25,29 @@ import (
|
||||
type CpLeaveServiceV1 struct {
|
||||
}
|
||||
|
||||
func requireIntField(value *int, message string) int {
|
||||
if value == nil {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(400, message))
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func validateLeaveApply(req *v1.CpLeaveApplyReq) int {
|
||||
leaveType := requireIntField(req.LeaveType, "请假类型不能为空")
|
||||
if req.OutStatus {
|
||||
if strings.TrimSpace(req.UrgencyMobile) == "" {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(400, "紧急联系人手机号不能为空"))
|
||||
}
|
||||
if strings.TrimSpace(req.Address) == "" {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(400, "目的地不能为空"))
|
||||
}
|
||||
if strings.TrimSpace(req.DetailAddress) == "" {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(400, "详细地址不能为空"))
|
||||
}
|
||||
}
|
||||
return leaveType
|
||||
}
|
||||
|
||||
// GetLeaveListFromSystem 获取今日校园系统中的请假列表
|
||||
func (v CpLeaveServiceV1) GetLeaveListFromSystem(ctx context.Context, req *v1.GetLeaveListFromSystemReq) *utils.Response[any] {
|
||||
user := utils.GetUserInfoFromCtx(ctx)
|
||||
@@ -208,3 +232,176 @@ func (v CpLeaveServiceV1) Upload(ctx context.Context, req *v1.CpLeaveUploadReq)
|
||||
func NewCpLeaveServiceV1() *CpLeaveServiceV1 {
|
||||
return &CpLeaveServiceV1{}
|
||||
}
|
||||
|
||||
// CheckLeave 检查是否允许请假
|
||||
func (v CpLeaveServiceV1) CheckLeave(ctx context.Context, req *v1.CpLeaveCheckReq) *utils.Response[any] {
|
||||
user := utils.GetUserInfoFromCtx(ctx)
|
||||
|
||||
cpUser := db2.Global.CpUser.Query().
|
||||
Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).
|
||||
WithSysuser().WithSchool().OnlyX(ctx)
|
||||
if cpUser.Edges.Sysuser == nil {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(400, "非法的今日校园账号"))
|
||||
}
|
||||
|
||||
schoolDetail := school.GetSchoolDetail(ctx, cpUser.SchoolID)
|
||||
refreshResult := login.EnsureCookieValid(ctx, &login.RefreshCookieParams{
|
||||
ID: cpUser.ID, Cookie: cpUser.Cookie, Tgc: cpUser.Tgc,
|
||||
LoginType: cpUser.LoginType, Username: cpUser.Username,
|
||||
Password: cpUser.Password, SchoolID: cpUser.SchoolID,
|
||||
}, &schoolDetail)
|
||||
if refreshResult.Cookie != cpUser.Cookie {
|
||||
db2.Global.CpUser.UpdateOneID(cpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
|
||||
}
|
||||
|
||||
reportApp := leave.NewCpdailyReportApp(refreshResult.Client, schoolDetail.WecloudUrl)
|
||||
err := reportApp.CheckOnceLeave()
|
||||
if err != nil {
|
||||
utils.OkOrPanic(err)
|
||||
}
|
||||
return utils.SuccessWithMsg("允许请假")
|
||||
}
|
||||
|
||||
// GetLeaveTypes 获取请假类型列表
|
||||
func (v CpLeaveServiceV1) GetLeaveTypes(ctx context.Context, req *v1.CpLeaveTypesReq) *utils.Response[any] {
|
||||
user := utils.GetUserInfoFromCtx(ctx)
|
||||
|
||||
cpUser := db2.Global.CpUser.Query().
|
||||
Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).
|
||||
WithSysuser().WithSchool().OnlyX(ctx)
|
||||
if cpUser.Edges.Sysuser == nil {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(400, "非法的今日校园账号"))
|
||||
}
|
||||
|
||||
schoolDetail := school.GetSchoolDetail(ctx, cpUser.SchoolID)
|
||||
refreshResult := login.EnsureCookieValid(ctx, &login.RefreshCookieParams{
|
||||
ID: cpUser.ID, Cookie: cpUser.Cookie, Tgc: cpUser.Tgc,
|
||||
LoginType: cpUser.LoginType, Username: cpUser.Username,
|
||||
Password: cpUser.Password, SchoolID: cpUser.SchoolID,
|
||||
}, &schoolDetail)
|
||||
if refreshResult.Cookie != cpUser.Cookie {
|
||||
db2.Global.CpUser.UpdateOneID(cpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
|
||||
}
|
||||
|
||||
reportApp := leave.NewCpdailyReportApp(refreshResult.Client, schoolDetail.WecloudUrl)
|
||||
leaveTypes := reportApp.QueryLeaveTypes()
|
||||
return utils.SuccessWithData[any](leaveTypes.Value())
|
||||
}
|
||||
|
||||
// GetLeaveTypeCfg 查询请假类型配置
|
||||
func (v CpLeaveServiceV1) GetLeaveTypeCfg(ctx context.Context, req *v1.CpLeaveTypeCfgReq) *utils.Response[any] {
|
||||
user := utils.GetUserInfoFromCtx(ctx)
|
||||
|
||||
cpUser := db2.Global.CpUser.Query().
|
||||
Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).
|
||||
WithSysuser().WithSchool().OnlyX(ctx)
|
||||
if cpUser.Edges.Sysuser == nil {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(400, "非法的今日校园账号"))
|
||||
}
|
||||
|
||||
schoolDetail := school.GetSchoolDetail(ctx, cpUser.SchoolID)
|
||||
refreshResult := login.EnsureCookieValid(ctx, &login.RefreshCookieParams{
|
||||
ID: cpUser.ID, Cookie: cpUser.Cookie, Tgc: cpUser.Tgc,
|
||||
LoginType: cpUser.LoginType, Username: cpUser.Username,
|
||||
Password: cpUser.Password, SchoolID: cpUser.SchoolID,
|
||||
}, &schoolDetail)
|
||||
if refreshResult.Cookie != cpUser.Cookie {
|
||||
db2.Global.CpUser.UpdateOneID(cpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
|
||||
}
|
||||
|
||||
reportApp := leave.NewCpdailyReportApp(refreshResult.Client, schoolDetail.WecloudUrl)
|
||||
cfg := reportApp.QueryLeaveTypeCfg(requireIntField(req.Code, "请假类型不能为空"))
|
||||
return utils.SuccessWithData[any](cfg.Value())
|
||||
}
|
||||
|
||||
// GetApproveFlow 获取审批人列表
|
||||
func (v CpLeaveServiceV1) GetApproveFlow(ctx context.Context, req *v1.CpLeaveApproveFlowReq) *utils.Response[any] {
|
||||
user := utils.GetUserInfoFromCtx(ctx)
|
||||
|
||||
cpUser := db2.Global.CpUser.Query().
|
||||
Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).
|
||||
WithSysuser().WithSchool().OnlyX(ctx)
|
||||
if cpUser.Edges.Sysuser == nil {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(400, "非法的今日校园账号"))
|
||||
}
|
||||
|
||||
schoolDetail := school.GetSchoolDetail(ctx, cpUser.SchoolID)
|
||||
refreshResult := login.EnsureCookieValid(ctx, &login.RefreshCookieParams{
|
||||
ID: cpUser.ID, Cookie: cpUser.Cookie, Tgc: cpUser.Tgc,
|
||||
LoginType: cpUser.LoginType, Username: cpUser.Username,
|
||||
Password: cpUser.Password, SchoolID: cpUser.SchoolID,
|
||||
}, &schoolDetail)
|
||||
if refreshResult.Cookie != cpUser.Cookie {
|
||||
db2.Global.CpUser.UpdateOneID(cpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
|
||||
}
|
||||
|
||||
reportApp := leave.NewCpdailyReportApp(refreshResult.Client, schoolDetail.WecloudUrl)
|
||||
flow := reportApp.ObtainApproveFlow(requireIntField(req.LeaveType, "请假类型不能为空"), req.StartTime, req.EndTime)
|
||||
return utils.SuccessWithData[any](flow.Value())
|
||||
}
|
||||
|
||||
// Apply 提交请假申请
|
||||
func (v CpLeaveServiceV1) Apply(ctx context.Context, req *v1.CpLeaveApplyReq) *utils.Response[any] {
|
||||
user := utils.GetUserInfoFromCtx(ctx)
|
||||
_ = validateLeaveApply(req)
|
||||
|
||||
cpUser := db2.Global.CpUser.Query().
|
||||
Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).
|
||||
WithSysuser().WithSchool().OnlyX(ctx)
|
||||
if cpUser.Edges.Sysuser == nil {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(400, "非法的今日校园账号"))
|
||||
}
|
||||
|
||||
// 计算扣款金额
|
||||
basePoint, err := decimal.NewFromString(db2.Global.SysConfig.Query().Where(sysconfig.KeyEQ(cst2.ConfigRedisCpdailyLeave)).OnlyX(ctx).Value)
|
||||
utils.OkOrPanic(err)
|
||||
if user.Edges.Agent != nil {
|
||||
basePoint = basePoint.Mul(user.Edges.Agent.Rate)
|
||||
}
|
||||
|
||||
// 先扣款
|
||||
balance.BalanceService.ChangeUserBalance(ctx, user.ID, basePoint.Neg(), "今日校园请假")
|
||||
|
||||
// 创建请假记录
|
||||
leaveRecord := db2.Global.CpLeave.Create().
|
||||
SetCpUserID(req.CpUserId).
|
||||
SetUserID(user.ID).
|
||||
SetLeaveID(fmt.Sprintf("apply_%d", req.CpUserId)).
|
||||
SetTitle(fmt.Sprintf("请假申请: %s ~ %s", req.StartTime, req.EndTime)).
|
||||
SetStatus(cpleave.StatusFail).
|
||||
SetType(cpleave.TypeApply).
|
||||
SetDesc("正在处理中...").
|
||||
SaveX(ctx)
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
balance.BalanceService.ChangeUserBalance(ctx, user.ID, basePoint, "请假失败退款")
|
||||
desc := fmt.Sprintf("请假失败: %v", r)
|
||||
db2.Global.CpLeave.UpdateOne(leaveRecord).
|
||||
SetStatus(cpleave.StatusFail).
|
||||
SetDesc(desc).
|
||||
ExecX(ctx)
|
||||
utils.OkOrPanic(errors2.NewBusinessError(500, desc))
|
||||
}
|
||||
}()
|
||||
|
||||
schoolDetail := school.GetSchoolDetail(ctx, cpUser.SchoolID)
|
||||
refreshResult := login.EnsureCookieValid(ctx, &login.RefreshCookieParams{
|
||||
ID: cpUser.ID, Cookie: cpUser.Cookie, Tgc: cpUser.Tgc,
|
||||
LoginType: cpUser.LoginType, Username: cpUser.Username,
|
||||
Password: cpUser.Password, SchoolID: cpUser.SchoolID,
|
||||
}, &schoolDetail)
|
||||
if refreshResult.Cookie != cpUser.Cookie {
|
||||
db2.Global.CpUser.UpdateOneID(cpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
|
||||
}
|
||||
|
||||
reportApp := leave.NewCpdailyReportApp(refreshResult.Client, schoolDetail.WecloudUrl)
|
||||
reportApp.SubmitLeaveApply(ctx, req, cpUser.Username, cpUser.MobileCode)
|
||||
|
||||
db2.Global.CpLeave.UpdateOne(leaveRecord).
|
||||
SetStatus(cpleave.StatusDone).
|
||||
SetDesc("已提交").
|
||||
ExecX(ctx)
|
||||
|
||||
return utils.SuccessWithMsg("请假申请提交成功")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"likedream-goframe/internal/framework/schedule"
|
||||
"likedream-goframe/internal/service/balance"
|
||||
"likedream-goframe/internal/utils"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent/dialect/sql"
|
||||
@@ -88,6 +90,18 @@ func (v CpTaskServiceV1) List(ctx context.Context, req *v1.CpTaskListReq) *utils
|
||||
if req.TaskStatus != "" {
|
||||
query.Where(cptask.TaskStatusEQ(req.TaskStatus))
|
||||
}
|
||||
if req.Ids != "" {
|
||||
var ids []int64
|
||||
for _, s := range strings.Split(req.Ids, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if id, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
query.Where(cptask.IDIn(ids...))
|
||||
}
|
||||
}
|
||||
total := query.CountX(ctx)
|
||||
var data []*ent.CpTask
|
||||
if total > 0 {
|
||||
@@ -216,6 +230,18 @@ func (v CpTaskServiceV1) PostExecute(ctx context.Context, req *v1.PostCpTaskExec
|
||||
return utils.SuccessWithMsg("更新成功")
|
||||
}
|
||||
|
||||
func (v CpTaskServiceV1) UpdateTaskTitle(ctx context.Context, req *v1.UpdateTaskTitleReq) *utils.Response[any] {
|
||||
user := utils.GetUserInfoFromCtx(ctx)
|
||||
cpTask := db2.Global.CpTask.Query().
|
||||
Where(cptask.ID(req.Id), cptask.HasSysUserWith(sysuser.ID(user.ID))).
|
||||
OnlyX(ctx)
|
||||
if cpTask == nil {
|
||||
utils.OkOrPanic(errors2.NewBusinessError(404, "任务不存在"))
|
||||
}
|
||||
db2.Global.CpTask.UpdateOne(cpTask).SetTitle(req.Title).ExecX(ctx)
|
||||
return utils.SuccessWithMsg("修改成功")
|
||||
}
|
||||
|
||||
func (v CpTaskServiceV1) Delete(ctx context.Context, req *v1.DeleteTaskReq) *utils.Response[any] {
|
||||
user := utils.GetUserInfoFromCtx(ctx)
|
||||
// 检测是否属于自己的cpTask
|
||||
@@ -397,14 +423,16 @@ func (v CpTaskServiceV1) GetTaskStatisticsV2(ctx context.Context, _ *v1.GetTaskS
|
||||
ScanX(ctx, &failIds)
|
||||
|
||||
item := &v1.TaskStatisticsV2Item{
|
||||
Date: dateStr,
|
||||
SuccessTask: len(successIds),
|
||||
FailTask: len(failIds),
|
||||
Date: dateStr,
|
||||
SuccessTask: len(successIds),
|
||||
FailTask: len(failIds),
|
||||
SuccessTaskIds: successIds,
|
||||
FailTaskIds: failIds,
|
||||
}
|
||||
|
||||
// 仅当天查待执行任务数
|
||||
// 仅当天查待执行任务数及ID列表
|
||||
if startOfDay.Equal(today) {
|
||||
item.PendingTask = db2.Global.CpTask.Query().
|
||||
pendingIds := db2.Global.CpTask.Query().
|
||||
Where(
|
||||
cptask.HasSysUserWith(sysuser.ID(user.ID)),
|
||||
cptask.TaskStatusNEQ(cptask.TaskStatusPause),
|
||||
@@ -413,7 +441,9 @@ func (v CpTaskServiceV1) GetTaskStatisticsV2(ctx context.Context, _ *v1.GetTaskS
|
||||
Where(func(s *sql.Selector) {
|
||||
s.Where(sql.ExprP("frequency > success_count"))
|
||||
}).
|
||||
CountX(ctx)
|
||||
IDsX(ctx)
|
||||
item.PendingTask = len(pendingIds)
|
||||
item.PendingTaskIds = pendingIds
|
||||
}
|
||||
|
||||
statistics[6-i] = item
|
||||
|
||||
@@ -240,3 +240,22 @@ func (v CpUserServiceV1) LoginUrl(ctx context.Context, req *v1.CpUserLoginUrlReq
|
||||
sc := db.Global.CpSchool.Query().Where(cpschool.NameEQ(req.Name)).Limit(1).OnlyX(ctx)
|
||||
return utils.SuccessWithData[any](school.GetSchoolDetail(ctx, sc.ID))
|
||||
}
|
||||
|
||||
// GenerateQr 生成二维码UUID
|
||||
func (v CpUserServiceV1) GenerateQr(ctx context.Context, req *v1.CpUserGenerateQrReq) *utils.Response[any] {
|
||||
uuid := utils.UUID()
|
||||
expire := time.Duration(cst.RedisCpUserQrCodeExpire) * time.Second
|
||||
redis.GetClient().Set(ctx, cst.RedisCpUserQrCode+uuid, "1", expire)
|
||||
return utils.SuccessWithData[any](map[string]string{
|
||||
"uuid": uuid,
|
||||
})
|
||||
}
|
||||
|
||||
// CheckQr 校验二维码UUID是否有效(无需登录)
|
||||
func (v CpUserServiceV1) CheckQr(ctx context.Context, req *v1.CpUserCheckQrReq) *utils.Response[any] {
|
||||
val := redis.GetClient().Get(ctx, cst.RedisCpUserQrCode+req.Uuid).Val()
|
||||
if val == "" {
|
||||
return utils.Fail(400, "二维码无效,请重新获取")
|
||||
}
|
||||
return utils.SuccessWithMsg("二维码有效")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
)
|
||||
|
||||
// Brand 品牌标识
|
||||
type Brand string
|
||||
|
||||
const (
|
||||
BrandDefault Brand = "default" // 老系统(若梦系统)- MD3 风格
|
||||
BrandNew Brand = "new" // 新系统(天神系统)- shadcn 风格
|
||||
)
|
||||
|
||||
// BrandConfig 品牌配置
|
||||
type BrandConfig struct {
|
||||
AppName string
|
||||
AppCopyright string
|
||||
EmailConfirmBaseUrl string
|
||||
EmailResetPasswordUrl string
|
||||
BalanceMigrateUrl string
|
||||
}
|
||||
|
||||
var (
|
||||
currentBrand Brand
|
||||
brandConfig *BrandConfig
|
||||
brandOnce sync.Once
|
||||
)
|
||||
|
||||
// initBrand 从 config.toml 读取品牌配置(只执行一次)
|
||||
func initBrand() {
|
||||
ctx := gctx.New()
|
||||
cfg := g.Cfg()
|
||||
|
||||
// app.brand: "default" 或 "new",未设置默认 "default"
|
||||
brandStr := cfg.MustGet(ctx, "app.brand", "default").String()
|
||||
if brandStr == "new" {
|
||||
currentBrand = BrandNew
|
||||
} else {
|
||||
currentBrand = BrandDefault
|
||||
}
|
||||
|
||||
brandConfig = &BrandConfig{
|
||||
AppName: cfg.MustGet(ctx, "app.name", "若梦系统").String(),
|
||||
AppCopyright: cfg.MustGet(ctx, "app.copyright", "© 2025 若梦系统. 保留所有权利.").String(),
|
||||
EmailConfirmBaseUrl: cfg.MustGet(ctx, "app.emailConfirmBaseUrl", cfg.MustGet(ctx, "server.emailConfirmBaseUrl").String()).String(),
|
||||
EmailResetPasswordUrl: cfg.MustGet(ctx, "app.emailResetPasswordUrl", cfg.MustGet(ctx, "server.emailResetPasswordUrl").String()).String(),
|
||||
BalanceMigrateUrl: cfg.MustGet(ctx, "app.balanceMigrateUrl", cfg.MustGet(ctx, "server.balanceMigrateUrl").String()).String(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentBrand 获取当前品牌标识
|
||||
func GetCurrentBrand() Brand {
|
||||
brandOnce.Do(initBrand)
|
||||
return currentBrand
|
||||
}
|
||||
|
||||
// GetBrandConfig 获取当前品牌配置
|
||||
func GetBrandConfig() *BrandConfig {
|
||||
brandOnce.Do(initBrand)
|
||||
return brandConfig
|
||||
}
|
||||
|
||||
// IsNewBrand 是否为新系统品牌
|
||||
func IsNewBrand() bool {
|
||||
return GetCurrentBrand() == BrandNew
|
||||
}
|
||||
@@ -5,85 +5,457 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
cst2 "likedream-goframe/internal/common/cst"
|
||||
db "likedream-goframe/internal/common/db"
|
||||
"likedream-goframe/internal/ent/sysconfig"
|
||||
"likedream-goframe/internal/framework/logger"
|
||||
email2 "likedream-goframe/internal/framework/model/email"
|
||||
"likedream-goframe/internal/framework/queue"
|
||||
"likedream-goframe/internal/utils"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/samber/oops"
|
||||
mail "github.com/wneessen/go-mail"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
//go:embed templates/*.gohtml
|
||||
var templateFiles embed.FS
|
||||
|
||||
type EmailServiceV1 struct {
|
||||
config EmailConfig
|
||||
templates *template.Template
|
||||
tmplOnce sync.Once
|
||||
}
|
||||
const (
|
||||
AuthModeSMTP = "smtp"
|
||||
AuthModeOutlookOAuth2 = "outlook_oauth2"
|
||||
|
||||
DefaultOutlookSMTPHost = "smtp-mail.outlook.com"
|
||||
DefaultOutlookSMTPPort = 587
|
||||
DefaultOutlookTenant = "consumers"
|
||||
DefaultOutlookOAuthScope = "offline_access https://graph.microsoft.com/Mail.Send"
|
||||
DefaultOutlookRedirectURL = "http://localhost:3200/api/v1/mail/oauth/outlook/callback"
|
||||
DefaultGraphSendMailURL = "https://graph.microsoft.com/v1.0/me/sendMail"
|
||||
)
|
||||
|
||||
type EmailServiceV1 struct{}
|
||||
|
||||
type EmailConfig struct {
|
||||
AuthMode string
|
||||
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
FromEmail string
|
||||
FromName string
|
||||
|
||||
FromEmail string
|
||||
FromName string
|
||||
|
||||
OAuthTenant string
|
||||
OAuthClientID string
|
||||
OAuthClientSecret string
|
||||
OAuthRedirectURL string
|
||||
OAuthRefreshToken string
|
||||
OAuthScope string
|
||||
}
|
||||
|
||||
func NewV1() *EmailServiceV1 {
|
||||
ctx := gctx.New()
|
||||
cfg := g.Cfg()
|
||||
return &EmailServiceV1{}
|
||||
}
|
||||
|
||||
config := EmailConfig{
|
||||
SMTPHost: cfg.MustGetWithEnv(ctx, cst2.ConfigEmailSMTPHost).String(),
|
||||
SMTPPort: cfg.MustGetWithEnv(ctx, cst2.ConfigEmailSMTPPort).Int(),
|
||||
SMTPUsername: cfg.MustGetWithEnv(ctx, cst2.ConfigEmailSMTPUsername).String(),
|
||||
SMTPPassword: cfg.MustGetWithEnv(ctx, cst2.ConfigEmailSMTPPassword).String(),
|
||||
FromEmail: cfg.MustGetWithEnv(ctx, cst2.ConfigEmailFromEmail).String(),
|
||||
FromName: cfg.MustGetWithEnv(ctx, cst2.ConfigEmailFromName).String(),
|
||||
func (s *EmailServiceV1) GetConfig() EmailConfig {
|
||||
ctx := gctx.New()
|
||||
|
||||
getVal := func(key string) string {
|
||||
config, err := db.Global.SysConfig.Query().
|
||||
Where(sysconfig.KeyEQ(key)).
|
||||
Only(ctx)
|
||||
if err == nil {
|
||||
return config.Value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
return &EmailServiceV1{
|
||||
config: config,
|
||||
port, _ := strconv.Atoi(getVal(cst2.ConfigEmailSMTPPort))
|
||||
|
||||
cfg := EmailConfig{
|
||||
AuthMode: getVal(cst2.ConfigEmailAuthMode),
|
||||
SMTPHost: getVal(cst2.ConfigEmailSMTPHost),
|
||||
SMTPPort: port,
|
||||
SMTPUsername: getVal(cst2.ConfigEmailSMTPUsername),
|
||||
SMTPPassword: getVal(cst2.ConfigEmailSMTPPassword),
|
||||
FromEmail: getVal(cst2.ConfigEmailFromEmail),
|
||||
FromName: getVal(cst2.ConfigEmailFromName),
|
||||
OAuthTenant: getVal(cst2.ConfigEmailOAuthTenant),
|
||||
OAuthClientID: getVal(cst2.ConfigEmailOAuthClientID),
|
||||
OAuthClientSecret: getVal(cst2.ConfigEmailOAuthClientSecret),
|
||||
OAuthRedirectURL: getVal(cst2.ConfigEmailOAuthRedirectURL),
|
||||
OAuthRefreshToken: getVal(cst2.ConfigEmailOAuthRefreshToken),
|
||||
OAuthScope: getVal(cst2.ConfigEmailOAuthScope),
|
||||
}
|
||||
|
||||
return NormalizeConfig(cfg)
|
||||
}
|
||||
|
||||
func NormalizeConfig(cfg EmailConfig) EmailConfig {
|
||||
if cfg.AuthMode == "" {
|
||||
cfg.AuthMode = AuthModeSMTP
|
||||
}
|
||||
if cfg.FromEmail != "" && cfg.SMTPUsername == "" {
|
||||
cfg.SMTPUsername = cfg.FromEmail
|
||||
}
|
||||
if cfg.AuthMode == AuthModeOutlookOAuth2 {
|
||||
cfg.OAuthTenant = DefaultOutlookTenant
|
||||
cfg.OAuthScope = DefaultOutlookOAuthScope
|
||||
cfg.OAuthRedirectURL = DefaultOutlookRedirectURL
|
||||
} else if cfg.SMTPPort == 0 {
|
||||
cfg.SMTPPort = 587
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) SaveAuthorizedOutlookConfig(ctx context.Context, cfg EmailConfig) error {
|
||||
cfg = NormalizeConfig(cfg)
|
||||
|
||||
configs := map[string]string{
|
||||
cst2.ConfigEmailAuthMode: AuthModeOutlookOAuth2,
|
||||
cst2.ConfigEmailFromEmail: cfg.FromEmail,
|
||||
cst2.ConfigEmailOAuthTenant: cfg.OAuthTenant,
|
||||
cst2.ConfigEmailOAuthClientID: cfg.OAuthClientID,
|
||||
cst2.ConfigEmailOAuthClientSecret: cfg.OAuthClientSecret,
|
||||
cst2.ConfigEmailOAuthRedirectURL: cfg.OAuthRedirectURL,
|
||||
cst2.ConfigEmailOAuthRefreshToken: cfg.OAuthRefreshToken,
|
||||
cst2.ConfigEmailOAuthScope: cfg.OAuthScope,
|
||||
}
|
||||
|
||||
for key, value := range configs {
|
||||
exists, err := db.Global.SysConfig.Query().Where(sysconfig.KeyEQ(key)).Exist(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
if _, err = db.Global.SysConfig.Update().Where(sysconfig.KeyEQ(key)).SetValue(value).Save(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if _, err = db.Global.SysConfig.Create().
|
||||
SetKey(key).
|
||||
SetValue(value).
|
||||
SetDescription("邮件配置").
|
||||
Save(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) ValidateConfig(cfg EmailConfig) error {
|
||||
cfg = NormalizeConfig(cfg)
|
||||
|
||||
if cfg.FromEmail == "" {
|
||||
return fmt.Errorf("fromEmail is required")
|
||||
}
|
||||
if cfg.FromName == "" {
|
||||
return fmt.Errorf("fromName is required")
|
||||
}
|
||||
|
||||
switch cfg.AuthMode {
|
||||
case AuthModeSMTP:
|
||||
if cfg.SMTPHost == "" {
|
||||
return fmt.Errorf("smtpHost is required")
|
||||
}
|
||||
if cfg.SMTPPort <= 0 {
|
||||
return fmt.Errorf("smtpPort is required")
|
||||
}
|
||||
if cfg.SMTPUsername == "" {
|
||||
return fmt.Errorf("smtpUsername is required")
|
||||
}
|
||||
if cfg.SMTPPassword == "" {
|
||||
return fmt.Errorf("smtpPassword is required")
|
||||
}
|
||||
case AuthModeOutlookOAuth2:
|
||||
if cfg.OAuthClientID == "" {
|
||||
return fmt.Errorf("oauthClientId is required")
|
||||
}
|
||||
if cfg.OAuthClientSecret == "" {
|
||||
return fmt.Errorf("oauthClientSecret is required")
|
||||
}
|
||||
if cfg.OAuthRefreshToken == "" {
|
||||
return fmt.Errorf("oauthRefreshToken is required")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported authMode: %s", cfg.AuthMode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) AccessToken(ctx context.Context, cfg EmailConfig) (string, error) {
|
||||
cfg = NormalizeConfig(cfg)
|
||||
if cfg.AuthMode != AuthModeOutlookOAuth2 {
|
||||
return "", fmt.Errorf("access token is only available in oauth2 mode")
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"client_id": {cfg.OAuthClientID},
|
||||
"client_secret": {cfg.OAuthClientSecret},
|
||||
"grant_type": {"refresh_token"},
|
||||
"refresh_token": {cfg.OAuthRefreshToken},
|
||||
"scope": {cfg.OAuthScope},
|
||||
}
|
||||
if cfg.OAuthRedirectURL != "" {
|
||||
form.Set("redirect_uri", cfg.OAuthRedirectURL)
|
||||
}
|
||||
|
||||
tokenURL := fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", cfg.OAuthTenant)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
Scope string `json:"scope"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Error string `json:"error"`
|
||||
ErrorDescription string `json:"error_description"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil {
|
||||
return "", fmt.Errorf("decode oauth token response failed: %w", err)
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if tokenResp.ErrorDescription != "" {
|
||||
return "", fmt.Errorf("oauth token refresh failed: %s (%s)", tokenResp.ErrorDescription, tokenResp.Error)
|
||||
}
|
||||
return "", fmt.Errorf("oauth token refresh failed with status %d", resp.StatusCode)
|
||||
}
|
||||
if tokenResp.AccessToken == "" {
|
||||
return "", fmt.Errorf("empty access token returned by oauth2 token endpoint")
|
||||
}
|
||||
return strings.TrimSpace(tokenResp.AccessToken), nil
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) newClient(ctx context.Context, cfg EmailConfig) (*mail.Client, error) {
|
||||
cfg = NormalizeConfig(cfg)
|
||||
if err := s.ValidateConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.AuthMode != AuthModeSMTP {
|
||||
return nil, fmt.Errorf("smtp client is only available in smtp mode")
|
||||
}
|
||||
|
||||
options := []mail.Option{
|
||||
mail.WithPort(cfg.SMTPPort),
|
||||
mail.WithTLSPortPolicy(mail.TLSMandatory),
|
||||
mail.WithTimeout(30 * time.Second),
|
||||
mail.WithUsername(cfg.SMTPUsername),
|
||||
}
|
||||
|
||||
options = append(options,
|
||||
mail.WithSMTPAuth(mail.SMTPAuthLogin),
|
||||
mail.WithPassword(cfg.SMTPPassword),
|
||||
)
|
||||
|
||||
return mail.NewClient(cfg.SMTPHost, options...)
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) newMessage(fromName, fromEmail, to, subject, body string) (*mail.Msg, error) {
|
||||
msg := mail.NewMsg()
|
||||
if err := msg.FromFormat(fromName, fromEmail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := msg.AddTo(to); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg.Subject(subject)
|
||||
msg.SetBodyString(mail.TypeTextHTML, body)
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) SendMessageNow(ctx context.Context, cfg EmailConfig, to, subject, body string) error {
|
||||
cfg = NormalizeConfig(cfg)
|
||||
if err := s.ValidateConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.AuthMode == AuthModeOutlookOAuth2 {
|
||||
return s.sendGraphMessageNow(ctx, cfg, to, subject, body)
|
||||
}
|
||||
|
||||
msg, err := s.newMessage(cfg.FromName, cfg.FromEmail, to, subject, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := s.newClient(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
return client.DialAndSendWithContext(ctx, msg)
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) sendGraphMessageNow(ctx context.Context, cfg EmailConfig, to, subject, body string) error {
|
||||
accessToken, err := s.AccessToken(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"message": map[string]any{
|
||||
"subject": subject,
|
||||
"body": map[string]any{
|
||||
"contentType": "HTML",
|
||||
"content": body,
|
||||
},
|
||||
"toRecipients": []map[string]any{
|
||||
{
|
||||
"emailAddress": map[string]any{
|
||||
"address": to,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"saveToSentItems": true,
|
||||
}
|
||||
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, DefaultGraphSendMailURL, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusAccepted {
|
||||
var graphErr struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&graphErr)
|
||||
if graphErr.Error.Message != "" {
|
||||
return fmt.Errorf("graph sendMail failed: %s (%s)", graphErr.Error.Message, graphErr.Error.Code)
|
||||
}
|
||||
return fmt.Errorf("graph sendMail failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ConfigFromPayload(payload email2.SendPayload) EmailConfig {
|
||||
return NormalizeConfig(EmailConfig{
|
||||
AuthMode: payload.AuthMode,
|
||||
SMTPHost: payload.SMTPHost,
|
||||
SMTPPort: payload.SMTPPort,
|
||||
SMTPUsername: payload.SMTPUsername,
|
||||
SMTPPassword: payload.SMTPPassword,
|
||||
FromEmail: payload.FromEmail,
|
||||
FromName: payload.FromName,
|
||||
OAuthTenant: payload.OAuthTenant,
|
||||
OAuthClientID: payload.OAuthClientID,
|
||||
OAuthClientSecret: payload.OAuthClientSecret,
|
||||
OAuthRedirectURL: payload.OAuthRedirectURL,
|
||||
OAuthRefreshToken: payload.OAuthRefreshToken,
|
||||
OAuthScope: payload.OAuthScope,
|
||||
})
|
||||
}
|
||||
|
||||
func PayloadFromConfig(cfg EmailConfig, to, subject, body string) *email2.SendPayload {
|
||||
cfg = NormalizeConfig(cfg)
|
||||
return &email2.SendPayload{
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
AuthMode: cfg.AuthMode,
|
||||
FromName: cfg.FromName,
|
||||
FromEmail: cfg.FromEmail,
|
||||
SMTPHost: cfg.SMTPHost,
|
||||
SMTPPort: cfg.SMTPPort,
|
||||
SMTPUsername: cfg.SMTPUsername,
|
||||
SMTPPassword: cfg.SMTPPassword,
|
||||
OAuthTenant: cfg.OAuthTenant,
|
||||
OAuthClientID: cfg.OAuthClientID,
|
||||
OAuthClientSecret: cfg.OAuthClientSecret,
|
||||
OAuthRedirectURL: cfg.OAuthRedirectURL,
|
||||
OAuthRefreshToken: cfg.OAuthRefreshToken,
|
||||
OAuthScope: cfg.OAuthScope,
|
||||
}
|
||||
}
|
||||
|
||||
// SendSignupEmail 发送注册成功邮件
|
||||
func templatePrefix() string {
|
||||
if IsNewBrand() {
|
||||
return "new_"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func brandAppName() string {
|
||||
return GetBrandConfig().AppName
|
||||
}
|
||||
|
||||
func brandCopyright() string {
|
||||
return GetBrandConfig().AppCopyright
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) SendSignupEmail(ctx context.Context, toEmail, username string, token string) *utils.Response[any] {
|
||||
if s.config.SMTPUsername == "" || s.config.SMTPPassword == "" {
|
||||
logger.Logger.Error("邮件服务未配置")
|
||||
cfg := s.GetConfig()
|
||||
if err := s.ValidateConfig(cfg); err != nil {
|
||||
logger.Logger.Error("mail config is invalid", zap.Error(err))
|
||||
return utils.FailBusiness("邮件服务未配置")
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("欢迎注册%s", cst2.AppName)
|
||||
subject := fmt.Sprintf("欢迎注册%s", brandAppName())
|
||||
body, err := s.renderSignupEmailTemplate(username, token)
|
||||
if err != nil {
|
||||
logger.Logger.Error("邮件模板渲染失败")
|
||||
logger.Logger.Error("render signup email failed", zap.Error(err))
|
||||
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
|
||||
}
|
||||
|
||||
err = s.sendEmail(ctx, toEmail, subject, body)
|
||||
if err != nil {
|
||||
logger.Logger.Error("邮件发送失败")
|
||||
if err = s.sendEmail(ctx, toEmail, subject, body); err != nil {
|
||||
logger.Logger.Error("send signup email failed", zap.Error(err))
|
||||
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
|
||||
}
|
||||
|
||||
return utils.SuccessWithMsg("注册邮件发送成功")
|
||||
}
|
||||
|
||||
// SendTaskNotificationEmail 发送任务状态通知邮件
|
||||
func (s *EmailServiceV1) SendTaskNotificationEmail(ctx context.Context, toEmail, taskName string, status email2.TaskStatus, details map[string]string) *utils.Response[any] {
|
||||
if s.config.SMTPUsername == "" || s.config.SMTPPassword == "" {
|
||||
cfg := s.GetConfig()
|
||||
if err := s.ValidateConfig(cfg); err != nil {
|
||||
return utils.FailBusiness("邮件服务未配置")
|
||||
}
|
||||
|
||||
@@ -93,192 +465,150 @@ func (s *EmailServiceV1) SendTaskNotificationEmail(ctx context.Context, toEmail,
|
||||
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
|
||||
}
|
||||
|
||||
err = s.sendEmail(ctx, toEmail, subject, body)
|
||||
if err != nil {
|
||||
if err = s.sendEmail(ctx, toEmail, subject, body); err != nil {
|
||||
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
|
||||
}
|
||||
|
||||
return utils.SuccessWithMsg("通知邮件发送成功")
|
||||
}
|
||||
|
||||
// SendResetPasswordEmail 发送重置密码邮件
|
||||
func (s *EmailServiceV1) SendResetPasswordEmail(ctx context.Context, toEmail, username string, token string) *utils.Response[any] {
|
||||
if s.config.SMTPUsername == "" || s.config.SMTPPassword == "" {
|
||||
logger.Logger.Error("邮件服务未配置")
|
||||
cfg := s.GetConfig()
|
||||
if err := s.ValidateConfig(cfg); err != nil {
|
||||
logger.Logger.Error("mail config is invalid", zap.Error(err))
|
||||
return utils.FailBusiness("邮件服务未配置")
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("重置%s账户密码", cst2.AppName)
|
||||
subject := fmt.Sprintf("重置%s账户密码", brandAppName())
|
||||
body, err := s.renderResetPasswordEmailTemplate(username, token)
|
||||
if err != nil {
|
||||
logger.Logger.Error("邮件模板渲染失败")
|
||||
logger.Logger.Error("render reset password email failed", zap.Error(err))
|
||||
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
|
||||
}
|
||||
|
||||
err = s.sendEmail(ctx, toEmail, subject, body)
|
||||
if err != nil {
|
||||
logger.Logger.Error("邮件发送失败")
|
||||
if err = s.sendEmail(ctx, toEmail, subject, body); err != nil {
|
||||
logger.Logger.Error("send reset password email failed", zap.Error(err))
|
||||
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
|
||||
}
|
||||
|
||||
return utils.SuccessWithMsg("重置密码邮件发送成功")
|
||||
}
|
||||
|
||||
// sendEmail 发送邮件的底层方法
|
||||
func (s *EmailServiceV1) sendEmail(ctx context.Context, to, subject, body string) error {
|
||||
|
||||
payload := bytes.NewBuffer(nil)
|
||||
|
||||
e := gob.NewEncoder(payload).Encode(&email2.SendPayload{
|
||||
To: to,
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
FromName: s.config.FromName,
|
||||
FromEmail: s.config.FromEmail,
|
||||
SMTPHost: s.config.SMTPHost,
|
||||
SMTPPort: s.config.SMTPPort,
|
||||
SMTPUsername: s.config.SMTPUsername,
|
||||
SMTPPassword: s.config.SMTPPassword,
|
||||
})
|
||||
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
|
||||
|
||||
task := queue.AddTask(ctx, asynq.NewTask(cst2.TaskEmail, payload.Bytes()), asynq.Queue(cst2.TaskQueueEmail),
|
||||
asynq.MaxRetry(1),
|
||||
asynq.Timeout(5*time.Minute),
|
||||
)
|
||||
logger.Logger.Info("添加任务: ", zap.String("task_id", task.ID), zap.String("邮箱", to))
|
||||
return nil
|
||||
}
|
||||
|
||||
// initTemplates 初始化邮件模板
|
||||
func (s *EmailServiceV1) initTemplates() error {
|
||||
var err error
|
||||
s.templates, err = template.ParseFS(templateFiles, "templates/*.gohtml")
|
||||
if err != nil {
|
||||
return fmt.Errorf("解析邮件模板失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTemplates 获取模板实例,使用sync.Once确保只初始化一次
|
||||
func (s *EmailServiceV1) getTemplates() (*template.Template, error) {
|
||||
var err error
|
||||
s.tmplOnce.Do(func() {
|
||||
err = s.initTemplates()
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.templates, nil
|
||||
}
|
||||
|
||||
// renderSignupEmailTemplate 渲染注册邮件模板
|
||||
func (s *EmailServiceV1) renderSignupEmailTemplate(username string, token string) (string, error) {
|
||||
// 单独解析注册确认模板
|
||||
tmpl, err := template.ParseFS(templateFiles, "templates/layout.gohtml", "templates/signup_confirm.gohtml")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析注册邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
get, err := g.Cfg().Get(gctx.New(), cst2.ConfigEmailConfirmBaseUrl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data := NewSignupTemplateData(username, cst2.AppName, cst2.AppCopyright, get.String()+token)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.ExecuteTemplate(&buf, "signup_confirm.gohtml", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("渲染注册邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// renderTaskNotificationTemplate 渲染任务通知邮件模板
|
||||
func (s *EmailServiceV1) renderTaskNotificationTemplate(taskName string, status email2.TaskStatus, details map[string]string) (string, error) {
|
||||
// 单独解析任务通知模板
|
||||
tmpl, err := template.ParseFS(templateFiles, "templates/layout.gohtml", "templates/task_notification.gohtml")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析任务通知邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
data := NewTaskNotificationTemplateData(taskName, status, details, cst2.AppName, cst2.AppCopyright)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.ExecuteTemplate(&buf, "task_notification.gohtml", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("渲染任务通知邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// renderResetPasswordEmailTemplate 渲染重置密码邮件模板
|
||||
func (s *EmailServiceV1) renderResetPasswordEmailTemplate(username string, token string) (string, error) {
|
||||
// 单独解析重置密码模板
|
||||
tmpl, err := template.ParseFS(templateFiles, "templates/layout.gohtml", "templates/reset_password.gohtml")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析重置密码邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
get, err := g.Cfg().Get(gctx.New(), cst2.ConfigEmailResetPasswordUrl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data := NewResetPasswordTemplateData(username, cst2.AppName, cst2.AppCopyright, get.String()+token)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.ExecuteTemplate(&buf, "reset_password.gohtml", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("渲染重置密码邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// SendBalanceMigrateEmail 发送积分转移邮件
|
||||
func (s *EmailServiceV1) SendBalanceMigrateEmail(ctx context.Context, toEmail, username string, balance int, token string) *utils.Response[any] {
|
||||
if s.config.SMTPUsername == "" || s.config.SMTPPassword == "" {
|
||||
logger.Logger.Error("邮件服务未配置")
|
||||
cfg := s.GetConfig()
|
||||
if err := s.ValidateConfig(cfg); err != nil {
|
||||
logger.Logger.Error("mail config is invalid", zap.Error(err))
|
||||
return utils.FailBusiness("邮件服务未配置")
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("%s积分转移通知", cst2.AppName)
|
||||
subject := fmt.Sprintf("%s积分转移通知", brandAppName())
|
||||
body, err := s.renderBalanceMigrateEmailTemplate(username, balance, token)
|
||||
if err != nil {
|
||||
logger.Logger.Error("邮件模板渲染失败")
|
||||
logger.Logger.Error("render balance migrate email failed", zap.Error(err))
|
||||
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
|
||||
}
|
||||
|
||||
err = s.sendEmail(ctx, toEmail, subject, body)
|
||||
if err != nil {
|
||||
logger.Logger.Error("邮件发送失败")
|
||||
if err = s.sendEmail(ctx, toEmail, subject, body); err != nil {
|
||||
logger.Logger.Error("send balance migrate email failed", zap.Error(err))
|
||||
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
|
||||
}
|
||||
|
||||
return utils.SuccessWithMsg("积分转移邮件发送成功")
|
||||
}
|
||||
|
||||
// renderBalanceMigrateEmailTemplate 渲染积分转移邮件模板
|
||||
func (s *EmailServiceV1) sendEmail(ctx context.Context, to, subject, body string) error {
|
||||
cfg := s.GetConfig()
|
||||
if err := s.ValidateConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := bytes.NewBuffer(nil)
|
||||
if err := gob.NewEncoder(payload).Encode(PayloadFromConfig(cfg, to, subject, body)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
task := queue.AddTask(
|
||||
ctx,
|
||||
asynq.NewTask(cst2.TaskEmail, payload.Bytes()),
|
||||
asynq.Queue(cst2.TaskQueueEmail),
|
||||
asynq.MaxRetry(1),
|
||||
asynq.Timeout(5*time.Minute),
|
||||
)
|
||||
logger.Logger.Info("add email task", zap.String("task_id", task.ID), zap.String("to", to))
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTemplates(contentTemplate string) (*template.Template, error) {
|
||||
prefix := templatePrefix()
|
||||
layoutFile := fmt.Sprintf("templates/%slayout.gohtml", prefix)
|
||||
contentFile := fmt.Sprintf("templates/%s%s", prefix, contentTemplate)
|
||||
return template.ParseFS(templateFiles, layoutFile, contentFile)
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) renderSignupEmailTemplate(username string, token string) (string, error) {
|
||||
tmpl, err := parseTemplates("signup_confirm.gohtml")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析注册邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
bc := GetBrandConfig()
|
||||
data := NewSignupTemplateData(username, bc.AppName, bc.AppCopyright, bc.EmailConfirmBaseUrl+token)
|
||||
|
||||
prefix := templatePrefix()
|
||||
var buf bytes.Buffer
|
||||
if err = tmpl.ExecuteTemplate(&buf, prefix+"signup_confirm.gohtml", data); err != nil {
|
||||
return "", fmt.Errorf("渲染注册邮件模板失败: %v", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) renderTaskNotificationTemplate(taskName string, status email2.TaskStatus, details map[string]string) (string, error) {
|
||||
tmpl, err := parseTemplates("task_notification.gohtml")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析任务通知邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
bc := GetBrandConfig()
|
||||
data := NewTaskNotificationTemplateData(taskName, status, details, bc.AppName, bc.AppCopyright)
|
||||
|
||||
prefix := templatePrefix()
|
||||
var buf bytes.Buffer
|
||||
if err = tmpl.ExecuteTemplate(&buf, prefix+"task_notification.gohtml", data); err != nil {
|
||||
return "", fmt.Errorf("渲染任务通知邮件模板失败: %v", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) renderResetPasswordEmailTemplate(username string, token string) (string, error) {
|
||||
tmpl, err := parseTemplates("reset_password.gohtml")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析重置密码邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
bc := GetBrandConfig()
|
||||
data := NewResetPasswordTemplateData(username, bc.AppName, bc.AppCopyright, bc.EmailResetPasswordUrl+token)
|
||||
|
||||
prefix := templatePrefix()
|
||||
var buf bytes.Buffer
|
||||
if err = tmpl.ExecuteTemplate(&buf, prefix+"reset_password.gohtml", data); err != nil {
|
||||
return "", fmt.Errorf("渲染重置密码邮件模板失败: %v", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) renderBalanceMigrateEmailTemplate(username string, balance int, token string) (string, error) {
|
||||
// 单独解析积分转移模板
|
||||
tmpl, err := template.ParseFS(templateFiles, "templates/layout.gohtml", "templates/balance_migrate.gohtml")
|
||||
tmpl, err := parseTemplates("balance_migrate.gohtml")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析积分转移邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
get, err := g.Cfg().Get(gctx.New(), cst2.ConfigBalanceMigrateUrl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data := NewBalanceMigrateTemplateData(username, cst2.AppName, cst2.AppCopyright, balance, get.String()+token)
|
||||
bc := GetBrandConfig()
|
||||
data := NewBalanceMigrateTemplateData(username, bc.AppName, bc.AppCopyright, balance, bc.BalanceMigrateUrl+token)
|
||||
|
||||
prefix := templatePrefix()
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.ExecuteTemplate(&buf, "balance_migrate.gohtml", data)
|
||||
if err != nil {
|
||||
if err = tmpl.ExecuteTemplate(&buf, prefix+"balance_migrate.gohtml", data); err != nil {
|
||||
return "", fmt.Errorf("渲染积分转移邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ package email
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"likedream-goframe/internal/common/cst"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEmailTemplates(t *testing.T) {
|
||||
service := &EmailServiceV1{}
|
||||
bc := GetBrandConfig()
|
||||
|
||||
// 测试注册邮件模板
|
||||
t.Run("signup_email_template", func(t *testing.T) {
|
||||
@@ -26,17 +26,17 @@ func TestEmailTemplates(t *testing.T) {
|
||||
t.Fatal("注册邮件模板中没有包含用户名")
|
||||
}
|
||||
|
||||
// 检查是否包含网站名称常量
|
||||
if !containsString(body, cst.AppName) {
|
||||
t.Fatalf("注册邮件模板中没有包含网站名称: %s", cst.AppName)
|
||||
// 检查是否包含品牌名称
|
||||
if !containsString(body, bc.AppName) {
|
||||
t.Fatalf("注册邮件模板中没有包含品牌名称: %s", bc.AppName)
|
||||
}
|
||||
|
||||
// 检查是否包含版权信息常量
|
||||
if !containsString(body, cst.AppCopyright) {
|
||||
t.Fatalf("注册邮件模板中没有包含版权信息: %s", cst.AppCopyright)
|
||||
// 检查是否包含版权信息
|
||||
if !containsString(body, bc.AppCopyright) {
|
||||
t.Fatalf("注册邮件模板中没有包含版权信息: %s", bc.AppCopyright)
|
||||
}
|
||||
|
||||
fmt.Printf("注册邮件模板渲染成功,长度: %d\n", len(body))
|
||||
fmt.Printf("注册邮件模板渲染成功 (品牌: %s),长度: %d\n", bc.AppName, len(body))
|
||||
})
|
||||
|
||||
// 测试任务通知邮件模板
|
||||
@@ -60,12 +60,56 @@ func TestEmailTemplates(t *testing.T) {
|
||||
t.Fatal("任务通知邮件模板中没有包含任务名称")
|
||||
}
|
||||
|
||||
// 检查是否包含版权信息常量
|
||||
if !containsString(body, cst.AppCopyright) {
|
||||
t.Fatalf("任务通知邮件模板中没有包含版权信息: %s", cst.AppCopyright)
|
||||
// 检查是否包含版权信息
|
||||
if !containsString(body, bc.AppCopyright) {
|
||||
t.Fatalf("任务通知邮件模板中没有包含版权信息: %s", bc.AppCopyright)
|
||||
}
|
||||
|
||||
fmt.Printf("任务通知邮件模板渲染成功,长度: %d\n", len(body))
|
||||
fmt.Printf("任务通知邮件模板渲染成功 (品牌: %s),长度: %d\n", bc.AppName, len(body))
|
||||
})
|
||||
|
||||
// 测试重置密码邮件模板
|
||||
t.Run("reset_password_template", func(t *testing.T) {
|
||||
body, err := service.renderResetPasswordEmailTemplate("测试用户", "test-token")
|
||||
if err != nil {
|
||||
t.Fatalf("渲染重置密码邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
if body == "" {
|
||||
t.Fatal("重置密码邮件模板内容为空")
|
||||
}
|
||||
|
||||
if !containsString(body, "测试用户") {
|
||||
t.Fatal("重置密码邮件模板中没有包含用户名")
|
||||
}
|
||||
|
||||
if !containsString(body, bc.AppName) {
|
||||
t.Fatalf("重置密码邮件模板中没有包含品牌名称: %s", bc.AppName)
|
||||
}
|
||||
|
||||
fmt.Printf("重置密码邮件模板渲染成功 (品牌: %s),长度: %d\n", bc.AppName, len(body))
|
||||
})
|
||||
|
||||
// 测试积分转移邮件模板
|
||||
t.Run("balance_migrate_template", func(t *testing.T) {
|
||||
body, err := service.renderBalanceMigrateEmailTemplate("测试用户", 100, "test-token")
|
||||
if err != nil {
|
||||
t.Fatalf("渲染积分转移邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
if body == "" {
|
||||
t.Fatal("积分转移邮件模板内容为空")
|
||||
}
|
||||
|
||||
if !containsString(body, "测试用户") {
|
||||
t.Fatal("积分转移邮件模板中没有包含用户名")
|
||||
}
|
||||
|
||||
if !containsString(body, "100") {
|
||||
t.Fatal("积分转移邮件模板中没有包含积分数量")
|
||||
}
|
||||
|
||||
fmt.Printf("积分转移邮件模板渲染成功 (品牌: %s),长度: %d\n", bc.AppName, len(body))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -86,8 +130,6 @@ func findSubstring(s, substr string) int {
|
||||
func TestEmailSending(t *testing.T) {
|
||||
//t.Skip("跳过真实邮件发送测试,需要SMTP配置")
|
||||
|
||||
// 这里可以添加真实的邮件发送测试
|
||||
// 需要配置真实的SMTP信息
|
||||
service := NewV1()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
v1 "likedream-goframe/internal/api/v1"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type outlookOAuthSession struct {
|
||||
Config EmailConfig
|
||||
FrontendOrigin string
|
||||
CallbackURL string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
var outlookOAuthStateStore = struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]outlookOAuthSession
|
||||
}{
|
||||
sessions: make(map[string]outlookOAuthSession),
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) BuildOutlookAuthorizeURL(cfg EmailConfig, callbackBaseURL, frontendOrigin string) (*v1.OutlookOAuthAuthorizeURLResp, error) {
|
||||
cfg = NormalizeConfig(cfg)
|
||||
if cfg.AuthMode != AuthModeOutlookOAuth2 {
|
||||
cfg.AuthMode = AuthModeOutlookOAuth2
|
||||
}
|
||||
if cfg.OAuthClientID == "" || cfg.OAuthClientSecret == "" {
|
||||
return nil, fmt.Errorf("client id and client secret are required")
|
||||
}
|
||||
if cfg.FromEmail == "" {
|
||||
return nil, fmt.Errorf("fromEmail is required")
|
||||
}
|
||||
|
||||
callbackURL, err := buildCallbackURL(callbackBaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = url.ParseRequestURI(frontendOrigin); err != nil {
|
||||
return nil, fmt.Errorf("frontendOrigin is invalid")
|
||||
}
|
||||
|
||||
state := uuid.NewString()
|
||||
oauthCfg := newOutlookOAuthConfig(cfg, callbackURL)
|
||||
authURL := oauthCfg.AuthCodeURL(
|
||||
state,
|
||||
oauth2.SetAuthURLParam("prompt", "consent"),
|
||||
oauth2.SetAuthURLParam("response_mode", "query"),
|
||||
)
|
||||
|
||||
outlookOAuthStateStore.mu.Lock()
|
||||
defer outlookOAuthStateStore.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, session := range outlookOAuthStateStore.sessions {
|
||||
if session.ExpiresAt.Before(now) {
|
||||
delete(outlookOAuthStateStore.sessions, key)
|
||||
}
|
||||
}
|
||||
outlookOAuthStateStore.sessions[state] = outlookOAuthSession{
|
||||
Config: cfg,
|
||||
FrontendOrigin: frontendOrigin,
|
||||
CallbackURL: callbackURL,
|
||||
ExpiresAt: now.Add(10 * time.Minute),
|
||||
}
|
||||
|
||||
return &v1.OutlookOAuthAuthorizeURLResp{
|
||||
AuthURL: authURL,
|
||||
CallbackURL: callbackURL,
|
||||
State: state,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *EmailServiceV1) ExchangeOutlookAuthorizeCode(ctx context.Context, state, code string) (string, string, error) {
|
||||
outlookOAuthStateStore.mu.Lock()
|
||||
session, ok := outlookOAuthStateStore.sessions[state]
|
||||
if ok {
|
||||
delete(outlookOAuthStateStore.sessions, state)
|
||||
}
|
||||
outlookOAuthStateStore.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("oauth state is invalid or expired")
|
||||
}
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
return "", "", fmt.Errorf("oauth state has expired")
|
||||
}
|
||||
if code == "" {
|
||||
return "", "", fmt.Errorf("authorization code is empty")
|
||||
}
|
||||
|
||||
token, err := newOutlookOAuthConfig(session.Config, session.CallbackURL).Exchange(ctx, code)
|
||||
if err != nil {
|
||||
return "", session.FrontendOrigin, err
|
||||
}
|
||||
if token.RefreshToken == "" {
|
||||
return "", session.FrontendOrigin, fmt.Errorf("no refresh token returned, please ensure offline_access scope is granted")
|
||||
}
|
||||
|
||||
session.Config.AuthMode = AuthModeOutlookOAuth2
|
||||
session.Config.OAuthRedirectURL = session.CallbackURL
|
||||
session.Config.OAuthRefreshToken = token.RefreshToken
|
||||
if err = s.SaveAuthorizedOutlookConfig(ctx, session.Config); err != nil {
|
||||
return "", session.FrontendOrigin, err
|
||||
}
|
||||
|
||||
return token.RefreshToken, session.FrontendOrigin, nil
|
||||
}
|
||||
|
||||
func BuildOutlookOAuthPopupHTML(targetOrigin string, success bool, message string, refreshToken string) string {
|
||||
payload := map[string]any{
|
||||
"source": "likedream-outlook-oauth",
|
||||
"success": success,
|
||||
"message": message,
|
||||
}
|
||||
if refreshToken != "" {
|
||||
payload["refreshToken"] = refreshToken
|
||||
}
|
||||
jsonPayload, _ := json.Marshal(payload)
|
||||
|
||||
return fmt.Sprintf(`<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Outlook OAuth</title>
|
||||
</head>
|
||||
<body>
|
||||
<script>
|
||||
(function () {
|
||||
const payload = %s;
|
||||
const targetOrigin = %q;
|
||||
if (window.opener && targetOrigin) {
|
||||
window.opener.postMessage(payload, targetOrigin);
|
||||
}
|
||||
document.body.innerText = payload.success ? '授权成功,窗口即将关闭。' : ('授权失败:' + payload.message);
|
||||
setTimeout(function () { window.close(); }, 300);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`, string(jsonPayload), targetOrigin)
|
||||
}
|
||||
|
||||
func BuildOutlookOAuthErrorHTML(message string) string {
|
||||
return fmt.Sprintf(`<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8" /><title>Outlook OAuth Error</title></head>
|
||||
<body>%s</body>
|
||||
</html>`, html.EscapeString(message))
|
||||
}
|
||||
|
||||
func buildCallbackURL(base string) (string, error) {
|
||||
return DefaultOutlookRedirectURL, nil
|
||||
}
|
||||
|
||||
func newOutlookOAuthConfig(cfg EmailConfig, callbackURL string) *oauth2.Config {
|
||||
cfg = NormalizeConfig(cfg)
|
||||
return &oauth2.Config{
|
||||
ClientID: cfg.OAuthClientID,
|
||||
ClientSecret: cfg.OAuthClientSecret,
|
||||
RedirectURL: callbackURL,
|
||||
Scopes: strings.Fields(cfg.OAuthScope),
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/authorize", cfg.OAuthTenant),
|
||||
TokenURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", cfg.OAuthTenant),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -111,30 +111,3 @@ func NewBalanceMigrateTemplateData(username, appName, copyright string, balance
|
||||
MigrateURL: migrateURL,
|
||||
}
|
||||
}
|
||||
|
||||
// TemplateType 模板类型枚举
|
||||
type TemplateType string
|
||||
|
||||
const (
|
||||
TemplateTypeSignup TemplateType = "signup" // 注册模板
|
||||
TemplateTypeTaskNotification TemplateType = "task_notification" // 任务通知模板
|
||||
TemplateTypeResetPassword TemplateType = "reset_password" // 重置密码模板
|
||||
TemplateTypeBalanceMigrate TemplateType = "balance_migrate" // 积分转移模板
|
||||
)
|
||||
|
||||
// GetTemplatePath 获取模板文件路径
|
||||
func (t TemplateType) GetTemplatePath() string {
|
||||
switch t {
|
||||
case TemplateTypeSignup:
|
||||
return "internal/service/email/templates/signup.gohtml"
|
||||
case TemplateTypeTaskNotification:
|
||||
return "internal/service/email/templates/task_notification.gohtml"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// GetLayoutPath 获取布局模板路径
|
||||
func GetLayoutPath() string {
|
||||
return "internal/service/email/templates/layout.gohtml"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{{- /*gotype: likedream-goframe/internal/service/email.BalanceMigrateTemplateData*/ -}}
|
||||
|
||||
{{template "layout" .}}
|
||||
|
||||
{{define "extra-styles"}}
|
||||
<style>
|
||||
.balance-card {
|
||||
border: 1px solid hsl(240 5.9% 90%);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin: 16px 0;
|
||||
background: hsl(240 4.8% 95.9%);
|
||||
}
|
||||
.balance-label {
|
||||
font-size: 13px;
|
||||
color: hsl(240 3.8% 46.1%);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.balance-amount {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: hsl(240 10% 3.9%);
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<p>你好 <span class="user-badge">{{.Username}}</span>,</p>
|
||||
<p>检测到你在旧系统中有积分余额,可以转移到新系统。</p>
|
||||
|
||||
<div class="balance-card">
|
||||
<div class="balance-label">旧系统积分余额</div>
|
||||
<div class="balance-amount">{{.Balance}}</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{.MigrateURL}}" class="btn-warning">转移积分</a>
|
||||
</div>
|
||||
|
||||
<div class="link-text">{{.MigrateURL}}</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<p><strong>注意事项</strong></p>
|
||||
<p>• 转移后旧系统积分将清空,请确认后操作</p>
|
||||
<p>• 每个账户仅可转移一次</p>
|
||||
<p>• 链接有效期 24 小时</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,189 @@
|
||||
{{- /*gotype: likedream-goframe/internal/service/email.BaseTemplateData*/ -}}
|
||||
{{define "layout"}}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.Title}}</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: hsl(240 10% 3.9%);
|
||||
background: hsl(0 0% 96%);
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
background: hsl(0 0% 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(240 5.9% 90%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 32px 32px 24px;
|
||||
border-bottom: 1px solid hsl(240 5.9% 90%);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.025em;
|
||||
color: hsl(240 10% 3.9%);
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 14px;
|
||||
color: hsl(240 3.8% 46.1%);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px 32px 32px;
|
||||
}
|
||||
|
||||
.content p {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 16px;
|
||||
color: hsl(240 3.8% 46.1%);
|
||||
}
|
||||
|
||||
.content strong {
|
||||
color: hsl(240 10% 3.9%);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
color: hsl(240 5.9% 10%);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
background: hsl(240 5.9% 10%);
|
||||
color: hsl(0 0% 98%) !important;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-destructive {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
background: hsl(0 84.2% 60.2%);
|
||||
color: hsl(0 0% 98%) !important;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
background: hsl(24.6 95% 53.1%);
|
||||
color: hsl(0 0% 98%) !important;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.link-text {
|
||||
font-size: 12px;
|
||||
color: hsl(240 3.8% 46.1%);
|
||||
word-break: break-all;
|
||||
background: hsl(240 4.8% 95.9%);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
margin: 16px 0;
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: hsl(214 95% 97%);
|
||||
border-color: hsl(214 82% 90%);
|
||||
color: hsl(214 60% 40%);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: hsl(48 96% 97%);
|
||||
border-color: hsl(48 80% 85%);
|
||||
color: hsl(32 60% 35%);
|
||||
}
|
||||
|
||||
.alert-destructive {
|
||||
background: hsl(0 86% 97%);
|
||||
border-color: hsl(0 74% 90%);
|
||||
color: hsl(0 60% 40%);
|
||||
}
|
||||
|
||||
.alert p {
|
||||
margin: 4px 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
border: none;
|
||||
border-top: 1px solid hsl(240 5.9% 90%);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
padding: 20px 32px;
|
||||
text-align: center;
|
||||
border-top: 1px solid hsl(240 5.9% 90%);
|
||||
background: hsl(240 4.8% 95.9%);
|
||||
}
|
||||
|
||||
.footer p {
|
||||
font-size: 12px;
|
||||
color: hsl(240 3.8% 46.1%);
|
||||
margin: 2px 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body { padding: 16px 8px; }
|
||||
.header, .content { padding-left: 20px; padding-right: 20px; }
|
||||
.footer { padding: 16px 20px; }
|
||||
}
|
||||
</style>
|
||||
{{block "extra-styles" .}}{{end}}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="header-title">{{.HeaderTitle}}</div>
|
||||
{{if .AppName}}<div class="header-desc">{{.AppName}}</div>{{end}}
|
||||
</div>
|
||||
<div class="content">
|
||||
{{block "content" .}}{{end}}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>{{.AppCopyright}}</p>
|
||||
<p>这是一封自动发送的邮件,请勿直接回复。</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{- /*gotype: likedream-goframe/internal/service/email.ResetPasswordTemplateData*/ -}}
|
||||
|
||||
{{template "layout" .}}
|
||||
|
||||
{{define "extra-styles"}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<p>你好 <span class="user-badge">{{.Username}}</span>,</p>
|
||||
<p>我们收到了重置 <strong>{{.AppName}}</strong> 账户密码的请求。点击下方按钮设置新密码:</p>
|
||||
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{.ResetURL}}" class="btn-destructive">重置密码</a>
|
||||
</div>
|
||||
|
||||
<div class="link-text">{{.ResetURL}}</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<p>⏰ 链接有效期为 1 小时。</p>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-destructive">
|
||||
<p><strong>安全提醒</strong></p>
|
||||
<p>如果这不是你本人的操作,请忽略此邮件,账户不会受到影响。</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{- /*gotype: likedream-goframe/internal/service/email.SignupTemplateData*/ -}}
|
||||
|
||||
{{template "layout" .}}
|
||||
|
||||
{{define "extra-styles"}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<p>你好 <span class="user-badge">{{.Username}}</span>,</p>
|
||||
<p>感谢注册 <strong>{{.AppName}}</strong>。请点击下方按钮完成邮箱验证:</p>
|
||||
|
||||
<div style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{.VerificationURL}}" class="btn-primary">验证邮箱</a>
|
||||
</div>
|
||||
|
||||
<div class="link-text">{{.VerificationURL}}</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<p>⏰ 链接有效期为 24 小时,过期需重新注册。</p>
|
||||
</div>
|
||||
|
||||
<hr class="separator">
|
||||
<p>如有问题请联系技术支持。</p>
|
||||
{{end}}
|
||||
@@ -0,0 +1,70 @@
|
||||
{{- /*gotype: likedream-goframe/internal/service/email.TaskNotificationTemplateData*/ -}}
|
||||
|
||||
{{template "layout" .}}
|
||||
|
||||
{{define "extra-styles"}}
|
||||
<style>
|
||||
.task-card {
|
||||
border: 1px solid hsl(240 5.9% 90%);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.task-card-header {
|
||||
padding: 12px 16px;
|
||||
background: hsl(240 4.8% 95.9%);
|
||||
border-bottom: 1px solid hsl(240 5.9% 90%);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.task-card-header span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: hsl(240 10% 3.9%);
|
||||
}
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.task-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid hsl(240 5.9% 90%);
|
||||
}
|
||||
.detail-row:last-child { border-bottom: none; }
|
||||
.detail-key { color: hsl(240 3.8% 46.1%); }
|
||||
.detail-val { color: hsl(240 10% 3.9%); font-weight: 500; }
|
||||
</style>
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="task-card">
|
||||
<div class="task-card-header">
|
||||
<span>{{.TaskName}}</span>
|
||||
<span class="status-tag" style="background: {{.Status.GetStatusColor}}20; color: {{.Status.GetStatusColor}};">{{.StatusText}}</span>
|
||||
</div>
|
||||
{{if .Details}}
|
||||
<div class="task-card-body">
|
||||
{{range $key, $value := .Details}}
|
||||
<div class="detail-row">
|
||||
<span class="detail-key">{{$key}}</span>
|
||||
<span class="detail-val">{{$value}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
<p>如需查看更多详情,请登录系统查看任务执行记录。</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -270,6 +270,9 @@ func (s *UserInfoServiceV1) RequestBalanceMigrate(ctx context.Context, req *v1.R
|
||||
}
|
||||
|
||||
// 查询旧系统数据库中是否存在该邮箱的用户
|
||||
if db.GlobalGorm == nil {
|
||||
return utils.SuccessWithMsg("如果该邮箱在旧系统中存在积分,转移链接已发送")
|
||||
}
|
||||
var oldUser AspNetUser
|
||||
err := db.GlobalGorm.Table("AspNetUsers").Where("email = ?", oldSystemEmail).First(&oldUser).Error
|
||||
if err != nil {
|
||||
@@ -343,6 +346,9 @@ func (s *UserInfoServiceV1) BalanceMigrate(ctx context.Context, req *v1.BalanceM
|
||||
}
|
||||
|
||||
// 查询旧系统用户
|
||||
if db.GlobalGorm == nil {
|
||||
return utils.FailParam("旧系统数据库未连接")
|
||||
}
|
||||
var oldUser AspNetUser
|
||||
err := db.GlobalGorm.Table("AspNetUsers").Where("email = ?", oldSystemEmail).First(&oldUser).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -54,6 +54,11 @@ func (s *UserLoginServiceV1) Register(ctx context.Context, req *v1.UserRegisterR
|
||||
return utils.FailParam("系统当前不允许注册")
|
||||
}
|
||||
|
||||
// 检查是否必须邀请码
|
||||
if s.IsInviteCodeRequired(ctx) && req.InviteCode == "" {
|
||||
return utils.FailParam("注册需要邀请码")
|
||||
}
|
||||
|
||||
//邀请码查询
|
||||
var parent *ent.SysUser
|
||||
if req.InviteCode != "" {
|
||||
@@ -277,3 +282,16 @@ func (s *UserLoginServiceV1) IsRegistrationAllowed(ctx context.Context) bool {
|
||||
// 检查配置值,"true" 或 "1" 表示允许注册
|
||||
return config.Value == "true" || config.Value == "1"
|
||||
}
|
||||
|
||||
// IsInviteCodeRequired 检查是否必须邀请码
|
||||
func (s *UserLoginServiceV1) IsInviteCodeRequired(ctx context.Context) bool {
|
||||
config, err := db.Global.SysConfig.Query().
|
||||
Where(sysconfig.KeyEQ(cst2.ConfigSystemRequireInviteCode)).
|
||||
Only(ctx)
|
||||
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return config.Value == "true" || config.Value == "1"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user