Compare commits

..

25 Commits

Author SHA1 Message Date
becomer a41341e732 fix: 修复历史任务经纬度/地址获取到的是教师边界坐标而非用户历史提交数据的问题
ci/woodpecker/tag/build Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
- sign.go: 移除 signInstanceWid 比较限制,当已签到任务与当前任务是同一实例时,
  直接复用已有详情数据作为历史数据,确保 ParseFormJson 能从 signPlaceSelected 提取用户真实坐标
- sleepcheck.go: 移除 if !isFromSigned 守卫条件,无论当前任务是否已签到都尝试获取历史数据
2026-07-02 00:36:42 +08:00
becomer ecfa50359b fix: add X-Api-Key header for captcha OCR requests
ci/woodpecker/push/build Pipeline was canceled
ci/woodpecker/tag/build Pipeline was successful
The captcha OCR service now requires X-Api-Key authentication.
Add captcha.apiKey config option and pass it as header in all
OCR-related API calls (ocr, semiOcr, calc).
2026-07-01 20:22:03 +08:00
becomer 2879336ce4 feat: make PostgreSQL connection optional
ci/woodpecker/push/build Pipeline was successful
Remove hard requirement on PG database (likedream-pg container).
PG is now only initialized when dsn2 is configured; skip gracefully when empty.
Add nil-safety checks on GlobalGorm references in balance migration logic.
Remove dsn2 from default config.
2026-07-01 14:08:36 +08:00
becomer f706968618 fix: force-remove temp container in cleanup step
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/build Pipeline was successful
2026-07-01 12:49:10 +08:00
becomer 69e198f745 fix: use wget instead of curl for health check
ci/woodpecker/push/build Pipeline was successful
The 'docker' CLI image does not include curl binary,
causing the health check to always fail. Switch to wget.
2026-07-01 12:47:05 +08:00
becomer 70f907d909 fix: replace shell ${VAR} with $VAR to avoid Woodpecker pre-processing
ci/woodpecker/push/build Pipeline failed
Woodpecker interprets ${...} as its own template syntax and strips
unrecognized variables. Using $VAR (no braces) passes through to bash.
2026-07-01 12:43:56 +08:00
becomer 3a3d17e9b4 feat: blue-green zero-downtime deploy via 1Panel
ci/woodpecker/push/build Pipeline failed
- Set up 1Panel website tianshen.eu.cc (HTTP 80, ready for HTTPS)
- /api/* → likedream-go with prefix stripping
- /* → likedream-web (Caddy, port 3080)
- Blue-green deployment: start on standby port → switch proxy → stop old → switch back
- Nginx reload is hot (zero connection drops)
- Detects current active port from proxy config automatically
2026-07-01 12:40:01 +08:00
becomer d560c9f678 fix: override clone remote to local Gitea URL
ci/woodpecker/push/build Pipeline is running
ci/woodpecker/tag/build Pipeline was successful
Add clone.git.settings.remote in .woodpecker/build.yml to use
fast local clone (http://10.10.10.128:3000) instead of external
Cloudflare-proxied URL (https://git.xsvb.cn) for tag builds.
2026-07-01 01:09:16 +08:00
becomer 7a9b49794c chore: retry with correct clone URL
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/build Pipeline is running
2026-07-01 00:56:01 +08:00
becomer e3f8dc8aac fix: set GOPROXY to goproxy.cn for Go module downloads
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/tag/build Pipeline is running
proxy.golang.org is not reachable from this server; use goproxy.cn mirror.
2026-07-01 00:39:04 +08:00
becomer 1228c30a75 chore: trigger ci with new forge url config
ci/woodpecker/push/build Pipeline failed
2026-07-01 00:30:25 +08:00
becomer 4762e71308 fix: replace tags filter with branch filter in when clause
ci/woodpecker/push/build Pipeline is running
ci/woodpecker/tag/build Pipeline was canceled
Woodpecker v3 does not support `tags` in the when clause; use `branch`
with glob patterns instead for matching tag events.
2026-07-01 00:02:44 +08:00
becomer 2082ee3e68 ci: support tag-triggered pipelines with versioned Docker images
- Allow triggering on v* tags in addition to pushes to main/master
- Tag Docker images with CI_COMMIT_TAG when triggered by a tag
2026-07-01 00:01:56 +08:00
becomer 10cb7d7ac6 ci: 添加 Woodpecker CI/CD 流水线
- 构建 Docker 镜像并推送
- 自动部署到本地 Docker
- 配置文件和网络管理
2026-06-30 23:51:01 +08:00
sugarBecomer 261153067c ✨ 后端支持统计返回任务ID + 列表按ID筛选
1. TaskStatisticsV2Item 新增 successTaskIds/failTaskIds/pendingTaskIds 字段
2. GetTaskStatisticsV2 返回每日对应的任务ID列表
3. CpTaskListReq 新增 ids 参数,List 方法支持按ID列表筛选
2026-04-24 12:32:54 +08:00
sugarBecomer 19a13a0ad2 ✨ 后端新增修改任务标题接口
新增 PUT /task/title/{id} 接口,支持修改今日校园任务标题
2026-04-23 10:41:40 +08:00
sugarBecomer 5d7f79a619 🐛 修改域名 2026-04-07 14:37:12 +08:00
sugarBecomer 0934994f51 🐛 修复邮件问题 2026-03-27 16:12:35 +08:00
sugarBecomer ef61d405ba 🐛 更新golang版本 2026-03-27 15:32:24 +08:00
sugarBecomer 60165e4851 🐛 请假问题 2026-03-27 00:38:15 +08:00
sugarBecomer 39cb44bc22 ✨ graph api发送邮件 2026-03-26 15:19:51 +08:00
sugarBecomer 9fa83657e3 ✨ graph api发送邮件 2026-03-26 15:19:43 +08:00
sugarBecomer 5a244c329e ✨ smtp服务 2026-03-26 11:37:38 +08:00
sugarBecomer 93f696d4df ✨ 新增是否必填邀请码 2026-03-25 14:49:11 +08:00
sugarBecomer 988cc7be6f ✨ 请假功能 2026-03-25 13:57:15 +08:00
33 changed files with 1507 additions and 326 deletions
+1 -1
View File
@@ -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
View File
@@ -4,4 +4,6 @@
node_modules
.claude
*.exe
.serena
.serena
device_code_cfworker
+130
View File
@@ -0,0 +1,130 @@
when:
- event: push
branch: [main, master]
- event: tag
branch: [v*]
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
+2 -1
View File
@@ -1,5 +1,6 @@
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
+6 -4
View File
@@ -20,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"
@@ -35,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"
+18
View File
@@ -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
+3 -2
View File
@@ -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
+4 -4
View File
@@ -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
View File
@@ -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() {
+57 -1
View File
@@ -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"`
}
+7 -4
View File
@@ -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 首页概览数据(新版)
+18 -23
View File
@@ -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,67 +15,62 @@ 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:"检查是否允许请假"`
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:"获取请假类型列表"`
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:"查询请假类型配置"`
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" v:"required#请假类型code不能为空"`
Code *int `json:"code"`
}
// 请假 - 获取审批人列表
type CpLeaveApproveFlowReq struct {
g.Meta `path:"/leave/approveFlow" method:"post" tags:"CpLeave" summary:"获取审批人列表"`
g.Meta `path:"/leave/approveFlow" method:"post" tags:"CpLeave" summary:"Get approve flow"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
LeaveType int `json:"leaveType" v:"required#请假类型不能为空"`
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:"提交请假申请"`
g.Meta `path:"/leave/apply" method:"post" tags:"CpLeave" summary:"Apply leave"`
CpUserId int64 `json:"cpUserId" v:"required#今日校园账号ID不能为空"`
LeaveType int `json:"leaveType" v:"required#请假类型不能为空"`
LeaveType *int `json:"leaveType"`
StartTime string `json:"startTime" v:"required#开始时间不能为空"`
EndTime string `json:"endTime" v:"required#结束时间不能为空"`
UrgencyMobile string `json:"urgencyMobile" v:"required#紧急联系人手机号不能为空"`
UrgencyMobile string `json:"urgencyMobile"`
LeaveReason string `json:"leaveReason" v:"required#请假原因不能为空"`
OutStatus bool `json:"outStatus"`
Address string `json:"address" v:"required#目的地不能为空"`
DetailAddress string `json:"detailAddress" v:"required#详细地址不能为空"`
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#定位地址不能为空"`
+7
View File
@@ -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"`
+6 -3
View File
@@ -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).
+167
View File
@@ -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()))
}
}
+20 -14
View File
@@ -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 {
// 如果已签到任务与当前匹配任务是同一个,直接用已有数据作为历史(避免重复API调用)
if matchTask != nil && signedMatch["signInstanceWid"] != nil && matchTask["signInstanceWid"] != nil &&
signedMatch["signInstanceWid"].String() == matchTask["signInstanceWid"].String() {
result["historyRows"] = result["rows"]
} 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)
}
}
}
}
+60 -60
View File
@@ -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 {
// 如果已签到任务与当前匹配任务是同一个,直接用已有数据作为历史(避免重复API调用)
if matchTask != nil && signedMatch["signInstanceWid"] != nil && matchTask["signInstanceWid"] != nil &&
signedMatch["signInstanceWid"].String() == matchTask["signInstanceWid"].String() {
result["historyRows"] = result["rows"]
} 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
+17 -13
View File
@@ -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 (
+17 -13
View File
@@ -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表示永久)
}
+22 -4
View File
@@ -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
}
@@ -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
@@ -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)
+16 -5
View File
@@ -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
}
+19 -36
View File
@@ -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
}
+159 -26
View File
@@ -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
}
+27 -2
View File
@@ -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)
@@ -286,7 +310,7 @@ func (v CpLeaveServiceV1) GetLeaveTypeCfg(ctx context.Context, req *v1.CpLeaveTy
}
reportApp := leave.NewCpdailyReportApp(refreshResult.Client, schoolDetail.WecloudUrl)
cfg := reportApp.QueryLeaveTypeCfg(req.Code)
cfg := reportApp.QueryLeaveTypeCfg(requireIntField(req.Code, "请假类型不能为空"))
return utils.SuccessWithData[any](cfg.Value())
}
@@ -312,13 +336,14 @@ func (v CpLeaveServiceV1) GetApproveFlow(ctx context.Context, req *v1.CpLeaveApp
}
reportApp := leave.NewCpdailyReportApp(refreshResult.Client, schoolDetail.WecloudUrl)
flow := reportApp.ObtainApproveFlow(req.LeaveType, req.StartTime, req.EndTime)
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))).
+36 -6
View File
@@ -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
+421 -86
View File
@@ -5,57 +5,417 @@ 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"
"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
}
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,
}
}
// templatePrefix 根据当前品牌返回模板文件前缀
func templatePrefix() string {
if IsNewBrand() {
return "new_"
@@ -63,43 +423,39 @@ func templatePrefix() string {
return ""
}
// brandAppName 获取当前品牌的应用名称
func brandAppName() string {
return GetBrandConfig().AppName
}
// brandCopyright 获取当前品牌的版权信息
func brandCopyright() string {
return GetBrandConfig().AppCopyright
}
// SendSignupEmail 发送注册成功邮件
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("邮件服务未配置")
}
appName := brandAppName()
subject := fmt.Sprintf("欢迎注册%s", 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("邮件服务未配置")
}
@@ -109,88 +465,79 @@ 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("邮件服务未配置")
}
appName := brandAppName()
subject := fmt.Sprintf("重置%s账户密码", 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("重置密码邮件发送成功")
}
// 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("邮件服务未配置")
}
appName := brandAppName()
subject := fmt.Sprintf("%s积分转移通知", 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("积分转移邮件发送成功")
}
// sendEmail 发送邮件的底层方法
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
}
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),
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))
logger.Logger.Info("add email task", zap.String("task_id", task.ID), zap.String("to", to))
return nil
}
// parseTemplates 根据当前品牌解析 layout + content 模板
func parseTemplates(contentTemplate string) (*template.Template, error) {
prefix := templatePrefix()
layoutFile := fmt.Sprintf("templates/%slayout.gohtml", prefix)
@@ -198,7 +545,6 @@ func parseTemplates(contentTemplate string) (*template.Template, error) {
return template.ParseFS(templateFiles, layoutFile, contentFile)
}
// renderSignupEmailTemplate 渲染注册邮件模板
func (s *EmailServiceV1) renderSignupEmailTemplate(username string, token string) (string, error) {
tmpl, err := parseTemplates("signup_confirm.gohtml")
if err != nil {
@@ -210,15 +556,12 @@ func (s *EmailServiceV1) renderSignupEmailTemplate(username string, token string
prefix := templatePrefix()
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, prefix+"signup_confirm.gohtml", data)
if err != nil {
if err = tmpl.ExecuteTemplate(&buf, prefix+"signup_confirm.gohtml", data); 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 := parseTemplates("task_notification.gohtml")
if err != nil {
@@ -230,15 +573,12 @@ func (s *EmailServiceV1) renderTaskNotificationTemplate(taskName string, status
prefix := templatePrefix()
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, prefix+"task_notification.gohtml", data)
if err != nil {
if err = tmpl.ExecuteTemplate(&buf, prefix+"task_notification.gohtml", data); err != nil {
return "", fmt.Errorf("渲染任务通知邮件模板失败: %v", err)
}
return buf.String(), nil
}
// renderResetPasswordEmailTemplate 渲染重置密码邮件模板
func (s *EmailServiceV1) renderResetPasswordEmailTemplate(username string, token string) (string, error) {
tmpl, err := parseTemplates("reset_password.gohtml")
if err != nil {
@@ -250,15 +590,12 @@ func (s *EmailServiceV1) renderResetPasswordEmailTemplate(username string, token
prefix := templatePrefix()
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, prefix+"reset_password.gohtml", data)
if err != nil {
if err = tmpl.ExecuteTemplate(&buf, prefix+"reset_password.gohtml", data); err != nil {
return "", fmt.Errorf("渲染重置密码邮件模板失败: %v", err)
}
return buf.String(), nil
}
// renderBalanceMigrateEmailTemplate 渲染积分转移邮件模板
func (s *EmailServiceV1) renderBalanceMigrateEmailTemplate(username string, balance int, token string) (string, error) {
tmpl, err := parseTemplates("balance_migrate.gohtml")
if err != nil {
@@ -270,10 +607,8 @@ func (s *EmailServiceV1) renderBalanceMigrateEmailTemplate(username string, bala
prefix := templatePrefix()
var buf bytes.Buffer
err = tmpl.ExecuteTemplate(&buf, prefix+"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
}
+177
View File
@@ -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),
},
}
}
@@ -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"
}