Compare commits

...

18 Commits

Author SHA1 Message Date
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
sugarBecomer b890929d0b ✨ 请假功能 2026-03-25 13:47:11 +08:00
sugarBecomer adbf885d95 🐛 调整两个版本邮件格式 2026-03-24 22:27:32 +08:00
sugarBecomer 99c58a3d5f 🐛 调整两个版本邮件格式 2026-03-24 22:13:02 +08:00
sugarBecomer c89f7e9bbb 🐛 调整dockerfile问题 2026-03-24 19:43:57 +08:00
sugarBecomer a6babba00d 🐛 适配新版ui 2026-03-24 19:36:16 +08:00
sugarBecomer 4c7bc950c7 🐛 调整定时任务时间 2026-03-16 13:29:39 +08:00
sugarBecomer b857d55e25 🐛 漏掉的文件 2026-03-16 12:14:05 +08:00
sugarBecomer ca20be8b7e 🐛 学校信息缓存 2026-03-16 11:20:19 +08:00
sugarBecomer ce98ce4afe 🐛 授权方式修复 2026-03-13 21:37:35 +08:00
sugarBecomer c65318c545 🐛 修复短信登录 2026-03-13 21:21:09 +08:00
59 changed files with 2467 additions and 368 deletions
+3 -1
View File
@@ -4,4 +4,6 @@
node_modules
.claude
*.exe
.serena
.serena
device_code_cfworker
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.24-alpine AS backend-builder
FROM golang:1.25-alpine AS backend-builder
WORKDIR /build
COPY . .
+12
View File
@@ -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"
+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"`
}
+20 -1
View File
@@ -1,8 +1,27 @@
package v1
// TaskStatisticsItem 每日任务统计项
// TaskStatisticsItem 每日任务统计项(旧版)
type TaskStatisticsItem struct {
Date string `json:"date"` // 日期 格式:2006-01-02
PendingTask int `json:"pendingTask"` // 需要完成的任务数
SuccessTask int `json:"successTask"` // 成功执行的任务数
}
// 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"` // 待执行任务数(仅当天)
}
// DashboardSummary 首页概览数据(新版)
type DashboardSummary struct {
Balance string `json:"balance"` // 当前余额
TodayCost string `json:"todayCost"` // 今日消费
YesterdayCost string `json:"yesterdayCost"` // 昨日消费
Week1Cost string `json:"week1Cost"` // 近7日消费
Week2Cost string `json:"week2Cost"` // 上7日消费
TodayTaskCount int `json:"todayTaskCount"` // 今日任务数(成功+失败,任务id去重)
YdayTaskCount int `json:"ydayTaskCount"` // 昨日任务数(成功+失败,任务id去重)
}
+13
View File
@@ -14,6 +14,19 @@ type UploadImageReq struct {
TaskType cptask.TaskType `json:"taskType" v:"required#请指定任务类型"`
}
type GetUploadPolicyReq struct {
g.Meta `path:"/image/policy" method:"post" tags:"Image" summary:"获取图片上传策略"`
CpUserId int64 `json:"cpUserId" v:"required#请指定今日校园用户"`
TaskType cptask.TaskType `json:"taskType" v:"required#请指定任务类型"`
}
type GetPreviewUrlReq struct {
g.Meta `path:"/image/previewUrl" method:"post" tags:"Image" summary:"获取图片预览地址"`
CpUserId int64 `json:"cpUserId" v:"required#请指定今日校园用户"`
TaskType cptask.TaskType `json:"taskType" v:"required#请指定任务类型"`
FileKey string `json:"fileKey" v:"required#请指定文件key"`
}
type PreviewImageReq struct {
g.Meta `path:"/image/preview" method:"get" tags:"Image" summary:"预览图片"`
Key string `json:"key" v:"required#请指定图片key" in:"query"`
+12
View File
@@ -3,3 +3,15 @@ package v1
type UploadImageRes struct {
PreviewUrl string `json:"previewUrl"`
}
type UploadPolicyRes struct {
Host string `json:"host"`
AccessId string `json:"accessId"`
FileName string `json:"fileName"`
Policy string `json:"policy"`
Signature string `json:"signature"`
}
type PreviewUrlRes struct {
PreviewUrl string `json:"previewUrl"`
}
+51 -7
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,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"`
}
+8
View File
@@ -77,3 +77,11 @@ type FindLocationReq struct {
type GetTaskStatisticsReq struct {
g.Meta `path:"/task/statistics" method:"get" tags:"CpTask" summary:"获取近七日任务统计数据"`
}
type GetTaskStatisticsV2Req struct {
g.Meta `path:"/task/statisticsV2" method:"get" tags:"CpTask" summary:"获取近七日任务统计数据V2"`
}
type GetDashboardSummaryReq struct {
g.Meta `path:"/task/dashboard" method:"get" tags:"CpTask" summary:"获取首页概览数据"`
}
+12
View File
@@ -89,4 +89,16 @@ type CpUserCustomDeviceReq struct {
g.Meta `path:"/user/customDevice" method:"post" tags:"CpUser" summary:"自定义设备码"`
Id int64 `json:"id" v:"required#请指定用户"`
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"`
}
+4
View File
@@ -4,6 +4,10 @@ import (
"github.com/gogf/gf/v2/frame/g"
)
type RefreshTokenReq struct {
g.Meta `path:"/refreshToken" method:"GET" tags:"UserInfo" summary:"刷新Token"`
}
type ResetPasswordReq struct {
g.Meta `path:"/resetPassword" method:"POST" tags:"UserInfo" summary:"重置密码"`
OldPassword string `json:"OldPassword"`
+2 -16
View File
@@ -48,22 +48,8 @@ func (p *PersistCookieJar) SetCookies(u *url.URL, cookies []*http.Cookie) {
delete(globalCookies, cookie.Name)
}
// 查找是否存在相同 name, path 和 domain 的 cookie
existingCookies := p.hostMap[host][cookie.Name]
found := false
for i, existing := range existingCookies {
if existing.Path == cookie.Path && existing.Domain == cookie.Domain {
// 找到完全匹配的 cookie,替换它
existingCookies[i] = cookie
found = true
break
}
}
if !found {
// 没有找到匹配的,添加新的 cookie
p.hostMap[host][cookie.Name] = append(p.hostMap[host][cookie.Name], cookie)
}
// 同一 host 下同名 cookie 直接覆盖,避免多个同名 cookie 导致旧值被选中
p.hostMap[host][cookie.Name] = []*http.Cookie{cookie}
}
}
+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()))
}
}
+69 -5
View File
@@ -15,6 +15,7 @@ import (
"likedream-goframe/internal/utils"
"net/http"
"net/url"
"strings"
"github.com/bytedance/sonic"
"github.com/samber/oops"
@@ -284,25 +285,88 @@ func (s *SmsLoginHandler) GetCookieString() string {
// RefreshByTgc 使用保存的完整 cookie(包含 CASTGC 等长期凭证)重新去学校换取 CAS cookie。
// tgc 参数是 SetLoginCookie 之后序列化的完整 cookie jar,包含 CASTGC(有效期10年)、
// sessionToken 等认证信息,可以直接用来重新激活 session。
// sessionToken 等认证信息。流程:先用 sessionToken 调用 getImUserToken 换取新的 CAS cookie,
// 再激活 session。
func (s *SmsLoginHandler) RefreshByTgc(ctx context.Context, tgc string, schoolId string) (cookie string, newTgc string) {
jar := cookiejar.NewPersistCookieJar()
jar.Deserialize(tgc)
// 合并所有 host 的 cookie 到 global,消除因多 host 存储导致的重复 cookie
jar.RemoveCookieInfo()
s.client = httpclient.NewCpdailyClient()
s.client.SetCookieJar(jar)
detail := school.GetSchoolDetail(ctx, schoolId)
logger.Logger.Info("[TGC] RefreshByTgc 开始用CASTGC重新激活session",
logger.Logger.Info("[TGC] RefreshByTgc 开始用TGC换取CAS cookie",
zap.String("schoolId", schoolId),
zap.String("wecloudUrl", detail.WecloudUrl),
zap.Int("tgcLen", len(tgc)),
)
// 用保存的 cookie(含 CASTGC)重新激活 session
ActivateSession(ctx, detail.WecloudUrl, s.client)
// 从 cookie jar 中提取 sessionToken,用于换取新的 CAS cookie
schoolUrl, _ := url.Parse(detail.WecloudUrl)
sessionToken := ""
for _, c := range jar.Cookies(schoolUrl) {
if c.Name == "sessionToken" {
sessionToken = c.Value
break
}
}
logger.Logger.Info("[TGC] RefreshByTgc ActivateSession完成")
if sessionToken != "" {
// 换取新的 CAS cookie(与 SetLoginCookie 相同的流程)
getCookieUrl := fmt.Sprintf("%s%s", detail.WecloudUrl, "/wec-im-group/group/imUserToken/getImUserToken")
ampCookieMap := map[string]any{
"AMP2": []map[string]any{
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
},
"AMP1": []map[string]any{
{"path": "/", "value": sessionToken, "domain": "", "name": "sessionToken"},
},
}
ampTokenString, e := sonic.Marshal(ampCookieMap)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
ampTokenEncrypted := cryptoutil.DesEncrypt(ampTokenString, cryptoutil.Normal)
ampToken := base64.StdEncoding.EncodeToString(ampTokenEncrypted)
ctx = utils.NewCpdailySmsContextBuilder().
WithDeviceID(s.deviceID).
WithTenantID(detail.Id).
WithSessionToken(sessionToken).
Build(ctx)
// 使用不复制 Cookie 头的重定向策略,避免旧 Cookie 覆盖 jar 中更新的值
s.client.SetRedirectPolicy(resty.RedirectPolicyFunc(func(req *http.Request, via []*http.Request) error {
if len(via) >= 50 {
return fmt.Errorf("resty: stopped after 50 redirects")
}
// 只复制非 Cookie 头,让 jar 自动管理 Cookie
for key, val := range via[0].Header {
if !strings.EqualFold(key, "Cookie") {
req.Header[key] = val
}
}
return nil
}))
req := s.client.R().
SetHeader("AmpCookies", ampToken).
SetContext(ctx)
setSmsRequestHeaders(ctx, req)
resp, e := req.Get(getCookieUrl)
utils.OkOrPanic(e, oops.WithContext(ctx).Wrap)
respJson := utils.ReadJson[cpdailySmsCommonResponse[any]](resp.Body)
respJson.EnsureSuccess()
logger.Logger.Info("[TGC] RefreshByTgc 换取CAS cookie完成")
} else {
logger.Logger.Warn("[TGC] RefreshByTgc 未找到sessionToken,跳过换取CAS cookie步骤")
}
newTgc = tgc // TGC 保持不变(CASTGC 有效期10年)
cookie = s.GetCookieString()
+16 -13
View File
@@ -28,21 +28,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 +55,13 @@ const (
ConfigRedisXxt = "price.xxt"
ConfigRedisSmarteduSc = "price.smartedu_sc"
// 第三方题库配置
ConfigQuesBankUrl = "quesbank.url"
ConfigQuesBankKey = "quesbank.key"
)
const (
// 应用程序常量
AppName = "若梦系统"
AppCopyright = "© 2025 若梦系统. 保留所有权利."
AppCopyright = "© 2025 若梦系统. 保留所有权利"
)
var (
+3 -1
View File
@@ -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)
RedisSchoolInfoExpire = int64(3600) // 学校信息1小时有效
RedisCpUserQrCodeExpire = int64(300) // 二维码5分钟有效
RedisSchoolInfoExpire = int64(3600 * 24 * 30) // 学校信息30天有效
)
+6 -5
View File
@@ -1,11 +1,12 @@
package cst
const (
TaskCpdaily = "task:cpdaily"
TaskEmail = "task:email"
TaskResetDaily = "task:reset:daily"
TaskXuexitong = "task:xuexitong"
TaskXuexitongVio = "task:xuexitong:violence"
TaskCpdaily = "task:cpdaily"
TaskEmail = "task:email"
TaskResetDaily = "task:reset:daily"
TaskRefreshSchoolDetail = "task:refresh:school:detail"
TaskXuexitong = "task:xuexitong"
TaskXuexitongVio = "task:xuexitong:violence"
TaskCpdailyId = "cpdaily:task:schedule:%d"
+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
}
@@ -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{}
}
@@ -71,6 +71,16 @@ func (c CpTaskCtrlV1) GetTaskStatistics(ctx context.Context, req *v1.GetTaskStat
return service.Global.CpTask.GetTaskStatistics(ctx, req), nil
}
func (c CpTaskCtrlV1) GetTaskStatisticsV2(ctx context.Context, req *v1.GetTaskStatisticsV2Req) (res *utils.Response[[]*v1.TaskStatisticsV2Item], err error) {
defer utils.Pass(&err)
return service.Global.CpTask.GetTaskStatisticsV2(ctx, req), nil
}
func (c CpTaskCtrlV1) GetDashboardSummary(ctx context.Context, req *v1.GetDashboardSummaryReq) (res *utils.Response[*v1.DashboardSummary], err error) {
defer utils.Pass(&err)
return service.Global.CpTask.GetDashboardSummary(ctx, req), nil
}
func NewCpTaskCtrlV1() ICpTaskCtrlV1 {
return &CpTaskCtrlV1{}
}
@@ -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)
}
@@ -20,4 +20,6 @@ type ICpTaskCtrlV1 interface {
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)
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)
}
@@ -9,4 +9,6 @@ import (
type ICpImageCtrlV1 interface {
Upload(ctx context.Context, req *v1.UploadImageReq) (res *utils.Response[any], err error)
Preview(ctx context.Context, req *v1.PreviewImageReq) (res *utils.Response[any], err error)
GetUploadPolicy(ctx context.Context, req *v1.GetUploadPolicyReq) (res *utils.Response[any], err error)
GetPreviewUrl(ctx context.Context, req *v1.GetPreviewUrlReq) (res *utils.Response[any], err error)
}
@@ -20,6 +20,16 @@ func (c CpImageCtrlV1) Preview(ctx context.Context, req *v1.PreviewImageReq) (re
return service.Global.CpImage.Preview(ctx, req), nil
}
func (c CpImageCtrlV1) GetUploadPolicy(ctx context.Context, req *v1.GetUploadPolicyReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpImage.GetUploadPolicy(ctx, req), nil
}
func (c CpImageCtrlV1) GetPreviewUrl(ctx context.Context, req *v1.GetPreviewUrlReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
return service.Global.CpImage.GetPreviewUrl(ctx, req), nil
}
func NewCpImageCtrlV1() ICpImageCtrlV1 {
return &CpImageCtrlV1{}
}
@@ -8,6 +8,8 @@ import (
)
type IUserInfoV1 interface {
// 刷新Token
RefreshToken(ctx context.Context, req *v1.RefreshTokenReq) (*utils.Response[any], error)
PermissionList(ctx context.Context, _ *v1.CommonReq) (*utils.Response[*v1.UserPermissionRes], error)
//用户基础信息
UserInfo(ctx context.Context, _ *v1.CommonReq) (*utils.Response[any], error)
@@ -3,6 +3,7 @@ package user_login
import (
"context"
v1 "likedream-goframe/internal/api/v1"
cst "likedream-goframe/internal/common/cst"
"likedream-goframe/internal/ent"
"likedream-goframe/internal/framework/captcha"
"likedream-goframe/internal/framework/redis"
@@ -10,6 +11,7 @@ import (
"likedream-goframe/internal/utils"
"github.com/bytedance/sonic"
"github.com/gogf/gf/v2/net/ghttp"
)
type UserInfoV1 struct {
@@ -19,6 +21,15 @@ func NewUserInfoV1() IUserInfoV1 {
return &UserInfoV1{}
}
// 刷新Token(token有效期内可换取新token)
func (c *UserInfoV1) RefreshToken(ctx context.Context, _ *v1.RefreshTokenReq) (res *utils.Response[any], err error) {
defer utils.Pass(&err)
user := utils.GetUserInfoFromCtx(ctx)
r := ghttp.RequestFromCtx(ctx)
r.Response.Header().Set(cst.AuthorizationKey, utils.GenerateToken(user.ID))
return utils.SuccessWithMsg("刷新成功"), nil
}
// 获取用户权限列表
func (c *UserInfoV1) PermissionList(ctx context.Context, _ *v1.CommonReq) (res *utils.Response[*v1.UserPermissionRes], err error) {
defer utils.Pass(&err)
+2 -1
View File
@@ -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)
+2 -1
View File
@@ -140,6 +140,7 @@ type MobileType string
const (
MobileTypeRandom MobileType = "random"
MobileTypeAuthorize MobileType = "authorize"
MobileTypeManual MobileType = "manual"
)
func (mt MobileType) String() string {
@@ -149,7 +150,7 @@ func (mt MobileType) String() string {
// MobileTypeValidator is a validator for the "mobile_type" field enum values. It is called by the builders before save.
func MobileTypeValidator(mt MobileType) error {
switch mt {
case MobileTypeRandom, MobileTypeAuthorize:
case MobileTypeRandom, MobileTypeAuthorize, MobileTypeManual:
return nil
default:
return fmt.Errorf("cpuser: invalid enum value for mobile_type field: %q", mt)
+2 -2
View File
@@ -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},
@@ -207,7 +207,7 @@ var (
{Name: "mobile_code", Type: field.TypeString, Nullable: true},
{Name: "cookie", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "tgc", Type: field.TypeString, Nullable: true, Size: 2147483647},
{Name: "mobile_type", Type: field.TypeEnum, Enums: []string{"random", "authorize"}},
{Name: "mobile_type", Type: field.TypeEnum, Enums: []string{"random", "authorize", "manual"}},
{Name: "login_type", Type: field.TypeEnum, Enums: []string{"account", "phone"}, Default: "account"},
{Name: "school_id", Type: field.TypeString},
{Name: "user_id", Type: field.TypeInt64},
+1 -1
View File
@@ -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 -1
View File
@@ -25,7 +25,7 @@ func (CpUser) Fields() []ent.Field {
field.String("mobile_code").Optional(),
field.Text("cookie").Optional(),
field.Text("tgc").Optional(),
field.Enum("mobile_type").Values("random", "authorize"),
field.Enum("mobile_type").Values("random", "authorize", "manual"),
field.Enum("login_type").Values("account", "phone").Default("account"),
}
}
+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
}
+9 -1
View File
@@ -36,7 +36,6 @@ type periodicTaskConfigProvider struct{}
func (p *periodicTaskConfigProvider) GetConfigs() ([]*asynq.PeriodicTaskConfig, error) {
return []*asynq.PeriodicTaskConfig{
{
//Cronspec: "0 1 * * *", // 每天凌晨1点执行
Cronspec: "0 1 * * * ",
Task: asynq.NewTask(cst.TaskResetDaily, nil),
Opts: []asynq.Option{
@@ -45,5 +44,14 @@ func (p *periodicTaskConfigProvider) GetConfigs() ([]*asynq.PeriodicTaskConfig,
asynq.Timeout(0), // 使用默认超时
},
},
{
Cronspec: "0 14 * * *",
Task: asynq.NewTask(cst.TaskRefreshSchoolDetail, nil),
Opts: []asynq.Option{
asynq.Queue(cst.TaskQueueDefault),
asynq.MaxRetry(1),
asynq.Timeout(0),
},
},
}, nil
}
@@ -0,0 +1,30 @@
package schedule
import (
"context"
"likedream-goframe/internal/application/cpdaily/school"
"likedream-goframe/internal/framework/logger"
"likedream-goframe/internal/utils"
"github.com/hibiken/asynq"
"go.uber.org/zap"
)
type RefreshSchoolDetailTaskHandler struct{}
// ProcessTask 每天中午刷新一次全部学校详情缓存
func (h *RefreshSchoolDetailTaskHandler) ProcessTask(ctx context.Context, _ *asynq.Task) (err error) {
defer utils.Pass(&err)
logger.Logger.Info("开始批量刷新学校详情缓存信息")
schools := school.GetSchoolList(ctx)
for _, item := range schools {
func() {
defer utils.HandleError(func(err error) {
logger.Logger.Warn("刷新学校详情缓存失败", zap.String("school_id", item.Id), zap.Error(err))
})
school.GetSchoolDetail(ctx, item.Id)
}()
}
return
}
+1
View File
@@ -68,6 +68,7 @@ func initServer(ctx context.Context) {
auxMux := asynq.NewServeMux()
auxMux.Handle(cst2.TaskEmail, &EmailTaskHandler{})
auxMux.Handle(cst2.TaskResetDaily, &ResetTaskHandler{})
auxMux.Handle(cst2.TaskRefreshSchoolDetail, &RefreshSchoolDetailTaskHandler{})
e := globalServer.Start(mainMux)
utils.OkOrPanic(e)
+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
}
+60
View File
@@ -110,3 +110,63 @@ func convertTaskType(ctx context.Context, tt cptask.TaskType) types.TaskType {
func NewCpImageServiceV1() *CpImageServiceV1 {
return &CpImageServiceV1{}
}
func (v CpImageServiceV1) GetUploadPolicy(ctx context.Context, req *v1.GetUploadPolicyReq) *utils.Response[any] {
user := utils.GetUserInfoFromCtx(ctx)
cpUser := db.Global.CpUser.Query().
Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).
WithSchool().
OnlyX(ctx)
if cpUser == 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 {
db.Global.CpUser.UpdateOneID(cpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
}
taskType := convertTaskType(ctx, req.TaskType)
policy := imageuploader.GetUploadPolicyAsync(ctx, schoolDetail.WecloudUrl, taskType, refreshResult.Client)
return utils.SuccessWithData[any](v1.UploadPolicyRes{
Host: policy.Host,
AccessId: policy.AccessId,
FileName: policy.FileName,
Policy: policy.Policy,
Signature: policy.Signature,
})
}
func (v CpImageServiceV1) GetPreviewUrl(ctx context.Context, req *v1.GetPreviewUrlReq) *utils.Response[any] {
user := utils.GetUserInfoFromCtx(ctx)
cpUser := db.Global.CpUser.Query().
Where(cpuser.ID(req.CpUserId), cpuser.HasSysuserWith(sysuser.ID(user.ID))).
WithSchool().
OnlyX(ctx)
if cpUser == 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 {
db.Global.CpUser.UpdateOneID(cpUser.ID).SetCookie(refreshResult.Cookie).SetTgc(refreshResult.Tgc).ExecX(ctx)
}
taskType := convertTaskType(ctx, req.TaskType)
previewUrl := imageuploader.GetPreviewUrlAsync(ctx, schoolDetail.WecloudUrl, req.FileKey, taskType, refreshResult.Client)
return utils.SuccessWithData[any](v1.PreviewUrlRes{
PreviewUrl: previewUrl,
})
}
+197
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)
@@ -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("请假申请提交成功")
}
+143
View File
@@ -351,6 +351,149 @@ func (v CpTaskServiceV1) GetTaskStatistics(ctx context.Context, req *v1.GetTaskS
return utils.SuccessWithData[[]*v1.TaskStatisticsItem](statistics)
}
// GetTaskStatisticsV2 近七日统计(从日志表按任务id去重,当天额外查待执行数)
func (v CpTaskServiceV1) GetTaskStatisticsV2(ctx context.Context, _ *v1.GetTaskStatisticsV2Req) *utils.Response[[]*v1.TaskStatisticsV2Item] {
user := utils.GetUserInfoFromCtx(ctx)
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
statistics := make([]*v1.TaskStatisticsV2Item, 7)
for i := 6; i >= 0; i-- {
date := now.AddDate(0, 0, -i)
dateStr := date.Format("2006-01-02")
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
endOfDay := startOfDay.AddDate(0, 0, 1)
// 成功任务数(按 cp_task_id 去重)
var successIds []int64
db2.Global.CpTaskLog.Query().
Where(func(s *sql.Selector) {
s.Where(sql.GTE(s.C("create_time"), startOfDay))
s.Where(sql.LT(s.C("create_time"), endOfDay))
s.Where(sql.EQ(s.C("is_success"), true))
s.Where(sql.EQ(s.C("system_user_id"), user.ID))
}).
Unique(false).
Select("cp_task_id").
Modify(func(s *sql.Selector) {
s.Distinct()
}).
ScanX(ctx, &successIds)
// 失败任务数(按 cp_task_id 去重)
var failIds []int64
db2.Global.CpTaskLog.Query().
Where(func(s *sql.Selector) {
s.Where(sql.GTE(s.C("create_time"), startOfDay))
s.Where(sql.LT(s.C("create_time"), endOfDay))
s.Where(sql.EQ(s.C("is_success"), false))
s.Where(sql.EQ(s.C("system_user_id"), user.ID))
}).
Unique(false).
Select("cp_task_id").
Modify(func(s *sql.Selector) {
s.Distinct()
}).
ScanX(ctx, &failIds)
item := &v1.TaskStatisticsV2Item{
Date: dateStr,
SuccessTask: len(successIds),
FailTask: len(failIds),
}
// 仅当天查待执行任务数
if startOfDay.Equal(today) {
item.PendingTask = db2.Global.CpTask.Query().
Where(
cptask.HasSysUserWith(sysuser.ID(user.ID)),
cptask.TaskStatusNEQ(cptask.TaskStatusPause),
cptask.FrequencyGT(0),
).
Where(func(s *sql.Selector) {
s.Where(sql.ExprP("frequency > success_count"))
}).
CountX(ctx)
}
statistics[6-i] = item
}
return utils.SuccessWithData[[]*v1.TaskStatisticsV2Item](statistics)
}
// GetDashboardSummary 首页概览:余额、消费对比、任务数对比
func (v CpTaskServiceV1) GetDashboardSummary(ctx context.Context, _ *v1.GetDashboardSummaryReq) *utils.Response[*v1.DashboardSummary] {
user := utils.GetUserInfoFromCtx(ctx)
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
yesterday := today.AddDate(0, 0, -1)
week1Start := today.AddDate(0, 0, -7)
week2Start := today.AddDate(0, 0, -14)
// 当前余额
balanceStr := user.Balance.String()
// 查询某时间段内的消费总额(type=decrease)
queryCost := func(start, end time.Time) string {
type SumResult struct {
Total *string `json:"total"`
}
var results []SumResult
db2.Global.SysBalanceRecord.Query().
Where(func(s *sql.Selector) {
s.Where(sql.EQ(s.C("user_id"), user.ID))
s.Where(sql.EQ(s.C("type"), "decrease"))
s.Where(sql.GTE(s.C("create_time"), start))
s.Where(sql.LT(s.C("create_time"), end))
}).
Modify(func(s *sql.Selector) {
s.Select(sql.As(sql.Sum(s.C("amount")), "total"))
}).
ScanX(ctx, &results)
if len(results) > 0 && results[0].Total != nil {
return *results[0].Total
}
return "0"
}
todayCost := queryCost(today, today.AddDate(0, 0, 1))
yesterdayCost := queryCost(yesterday, today)
week1Cost := queryCost(week1Start, today)
week2Cost := queryCost(week2Start, week1Start)
// 查询某天的任务数(成功+失败,按任务id去重)
queryTaskCount := func(start, end time.Time) int {
var taskIds []int64
db2.Global.CpTaskLog.Query().
Where(func(s *sql.Selector) {
s.Where(sql.GTE(s.C("create_time"), start))
s.Where(sql.LT(s.C("create_time"), end))
s.Where(sql.EQ(s.C("system_user_id"), user.ID))
}).
Unique(false).
Select("cp_task_id").
Modify(func(s *sql.Selector) {
s.Distinct()
}).
ScanX(ctx, &taskIds)
return len(taskIds)
}
todayTaskCount := queryTaskCount(today, today.AddDate(0, 0, 1))
ydayTaskCount := queryTaskCount(yesterday, today)
return utils.SuccessWithData[*v1.DashboardSummary](&v1.DashboardSummary{
Balance: balanceStr,
TodayCost: todayCost,
YesterdayCost: yesterdayCost,
Week1Cost: week1Cost,
Week2Cost: week2Cost,
TodayTaskCount: todayTaskCount,
YdayTaskCount: ydayTaskCount,
})
}
func NewCpTaskServiceV1() *CpTaskServiceV1 {
return &CpTaskServiceV1{}
}
+20 -1
View File
@@ -176,7 +176,7 @@ func (v CpUserServiceV1) CustomDevice(ctx context.Context, req *v1.CpUserCustomD
if cpUser == nil {
return utils.FailParam("用户不存在")
}
db.Global.CpUser.UpdateOne(cpUser).SetMobileCode(req.MobileCode).SetMobileType(cpuser.MobileTypeRandom).ExecX(ctx)
db.Global.CpUser.UpdateOne(cpUser).SetMobileCode(req.MobileCode).SetMobileType(cpuser.MobileType(req.MobileType)).ExecX(ctx)
return utils.SuccessWithMsg("自定义设备码设置成功")
}
@@ -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("二维码有效")
}
+70
View File
@@ -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
}
+504 -174
View File
@@ -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
}
+56 -14
View File
@@ -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()
+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),
},
}
}
-27
View File
@@ -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}}
@@ -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"
}