Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adbf885d95 | |||
| 99c58a3d5f | |||
| c89f7e9bbb | |||
| a6babba00d | |||
| 4c7bc950c7 | |||
| b857d55e25 |
+12
@@ -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"
|
||||
|
||||
@@ -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去重)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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:"获取首页概览数据"`
|
||||
}
|
||||
|
||||
@@ -91,3 +91,14 @@ type CpUserCustomDeviceReq struct {
|
||||
MobileCode string `json:"mobileCode" v:"required#请输入设备码"`
|
||||
MobileType string `json:"mobileType" v:"required|in:random,manual#请指定设备码类型|设备码类型无效"`
|
||||
}
|
||||
|
||||
// ---- 二维码获取设备码流程 ----
|
||||
|
||||
type CpUserGenerateQrReq struct {
|
||||
g.Meta `path:"/user/generateQr" method:"post" tags:"CpUser" summary:"生成设备码二维码"`
|
||||
}
|
||||
|
||||
type CpUserCheckQrReq struct {
|
||||
g.Meta `path:"/user/checkQr" method:"get" tags:"CpUser" summary:"校验二维码UUID是否有效" auth:"false"`
|
||||
Uuid string `json:"uuid" v:"required#请提供二维码UUID"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -7,6 +7,7 @@ const (
|
||||
RedisBalanceMigrate = "balancemigrate:" // 积分转移token
|
||||
RedisCpUserLogin = "cpuserlogin:"
|
||||
RedisCpUserAuthorize = "cpuser:authorize:"
|
||||
RedisCpUserQrCode = "cpuser:qrcode:" // 二维码UUID -> 系统用户ID
|
||||
RedisSchoolInfo = "cpuser:schoolinfo:"
|
||||
)
|
||||
|
||||
@@ -17,5 +18,6 @@ var (
|
||||
RedisBalanceMigrateExpire = int64(3600) // 积分转移链接1小时有效
|
||||
RedisCpUserLoginExpire = int64(180)
|
||||
RedisCpUserAuthorizeExpire = int64(1800)
|
||||
RedisCpUserQrCodeExpire = int64(300) // 二维码5分钟有效
|
||||
RedisSchoolInfoExpire = int64(3600 * 24 * 30) // 学校信息30天有效
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -45,7 +45,7 @@ func (p *periodicTaskConfigProvider) GetConfigs() ([]*asynq.PeriodicTaskConfig,
|
||||
},
|
||||
},
|
||||
{
|
||||
Cronspec: "0 12 * * *",
|
||||
Cronspec: "0 14 * * *",
|
||||
Task: asynq.NewTask(cst.TaskRefreshSchoolDetail, nil),
|
||||
Opts: []asynq.Option{
|
||||
asynq.Queue(cst.TaskQueueDefault),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -240,3 +240,22 @@ func (v CpUserServiceV1) LoginUrl(ctx context.Context, req *v1.CpUserLoginUrlReq
|
||||
sc := db.Global.CpSchool.Query().Where(cpschool.NameEQ(req.Name)).Limit(1).OnlyX(ctx)
|
||||
return utils.SuccessWithData[any](school.GetSchoolDetail(ctx, sc.ID))
|
||||
}
|
||||
|
||||
// GenerateQr 生成二维码UUID
|
||||
func (v CpUserServiceV1) GenerateQr(ctx context.Context, req *v1.CpUserGenerateQrReq) *utils.Response[any] {
|
||||
uuid := utils.UUID()
|
||||
expire := time.Duration(cst.RedisCpUserQrCodeExpire) * time.Second
|
||||
redis.GetClient().Set(ctx, cst.RedisCpUserQrCode+uuid, "1", expire)
|
||||
return utils.SuccessWithData[any](map[string]string{
|
||||
"uuid": uuid,
|
||||
})
|
||||
}
|
||||
|
||||
// CheckQr 校验二维码UUID是否有效(无需登录)
|
||||
func (v CpUserServiceV1) CheckQr(ctx context.Context, req *v1.CpUserCheckQrReq) *utils.Response[any] {
|
||||
val := redis.GetClient().Get(ctx, cst.RedisCpUserQrCode+req.Uuid).Val()
|
||||
if val == "" {
|
||||
return utils.Fail(400, "二维码无效,请重新获取")
|
||||
}
|
||||
return utils.SuccessWithMsg("二维码有效")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gctx"
|
||||
)
|
||||
|
||||
// Brand 品牌标识
|
||||
type Brand string
|
||||
|
||||
const (
|
||||
BrandDefault Brand = "default" // 老系统(若梦系统)- MD3 风格
|
||||
BrandNew Brand = "new" // 新系统(天神系统)- shadcn 风格
|
||||
)
|
||||
|
||||
// BrandConfig 品牌配置
|
||||
type BrandConfig struct {
|
||||
AppName string
|
||||
AppCopyright string
|
||||
EmailConfirmBaseUrl string
|
||||
EmailResetPasswordUrl string
|
||||
BalanceMigrateUrl string
|
||||
}
|
||||
|
||||
var (
|
||||
currentBrand Brand
|
||||
brandConfig *BrandConfig
|
||||
brandOnce sync.Once
|
||||
)
|
||||
|
||||
// initBrand 从 config.toml 读取品牌配置(只执行一次)
|
||||
func initBrand() {
|
||||
ctx := gctx.New()
|
||||
cfg := g.Cfg()
|
||||
|
||||
// app.brand: "default" 或 "new",未设置默认 "default"
|
||||
brandStr := cfg.MustGet(ctx, "app.brand", "default").String()
|
||||
if brandStr == "new" {
|
||||
currentBrand = BrandNew
|
||||
} else {
|
||||
currentBrand = BrandDefault
|
||||
}
|
||||
|
||||
brandConfig = &BrandConfig{
|
||||
AppName: cfg.MustGet(ctx, "app.name", "若梦系统").String(),
|
||||
AppCopyright: cfg.MustGet(ctx, "app.copyright", "© 2025 若梦系统. 保留所有权利.").String(),
|
||||
EmailConfirmBaseUrl: cfg.MustGet(ctx, "app.emailConfirmBaseUrl", cfg.MustGet(ctx, "server.emailConfirmBaseUrl").String()).String(),
|
||||
EmailResetPasswordUrl: cfg.MustGet(ctx, "app.emailResetPasswordUrl", cfg.MustGet(ctx, "server.emailResetPasswordUrl").String()).String(),
|
||||
BalanceMigrateUrl: cfg.MustGet(ctx, "app.balanceMigrateUrl", cfg.MustGet(ctx, "server.balanceMigrateUrl").String()).String(),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCurrentBrand 获取当前品牌标识
|
||||
func GetCurrentBrand() Brand {
|
||||
brandOnce.Do(initBrand)
|
||||
return currentBrand
|
||||
}
|
||||
|
||||
// GetBrandConfig 获取当前品牌配置
|
||||
func GetBrandConfig() *BrandConfig {
|
||||
brandOnce.Do(initBrand)
|
||||
return brandConfig
|
||||
}
|
||||
|
||||
// IsNewBrand 是否为新系统品牌
|
||||
func IsNewBrand() bool {
|
||||
return GetCurrentBrand() == BrandNew
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
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"
|
||||
@@ -26,9 +25,7 @@ import (
|
||||
var templateFiles embed.FS
|
||||
|
||||
type EmailServiceV1 struct {
|
||||
config EmailConfig
|
||||
templates *template.Template
|
||||
tmplOnce sync.Once
|
||||
config EmailConfig
|
||||
}
|
||||
|
||||
type EmailConfig struct {
|
||||
@@ -58,6 +55,24 @@ func NewV1() *EmailServiceV1 {
|
||||
}
|
||||
}
|
||||
|
||||
// templatePrefix 根据当前品牌返回模板文件前缀
|
||||
func templatePrefix() string {
|
||||
if IsNewBrand() {
|
||||
return "new_"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// brandAppName 获取当前品牌的应用名称
|
||||
func brandAppName() string {
|
||||
return GetBrandConfig().AppName
|
||||
}
|
||||
|
||||
// brandCopyright 获取当前品牌的版权信息
|
||||
func brandCopyright() string {
|
||||
return GetBrandConfig().AppCopyright
|
||||
}
|
||||
|
||||
// SendSignupEmail 发送注册成功邮件
|
||||
func (s *EmailServiceV1) SendSignupEmail(ctx context.Context, toEmail, username string, token string) *utils.Response[any] {
|
||||
if s.config.SMTPUsername == "" || s.config.SMTPPassword == "" {
|
||||
@@ -65,7 +80,8 @@ func (s *EmailServiceV1) SendSignupEmail(ctx context.Context, toEmail, username
|
||||
return utils.FailBusiness("邮件服务未配置")
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("欢迎注册%s", cst2.AppName)
|
||||
appName := brandAppName()
|
||||
subject := fmt.Sprintf("欢迎注册%s", appName)
|
||||
body, err := s.renderSignupEmailTemplate(username, token)
|
||||
if err != nil {
|
||||
logger.Logger.Error("邮件模板渲染失败")
|
||||
@@ -108,7 +124,8 @@ func (s *EmailServiceV1) SendResetPasswordEmail(ctx context.Context, toEmail, us
|
||||
return utils.FailBusiness("邮件服务未配置")
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("重置%s账户密码", cst2.AppName)
|
||||
appName := brandAppName()
|
||||
subject := fmt.Sprintf("重置%s账户密码", appName)
|
||||
body, err := s.renderResetPasswordEmailTemplate(username, token)
|
||||
if err != nil {
|
||||
logger.Logger.Error("邮件模板渲染失败")
|
||||
@@ -124,9 +141,32 @@ func (s *EmailServiceV1) SendResetPasswordEmail(ctx context.Context, toEmail, us
|
||||
return utils.SuccessWithMsg("重置密码邮件发送成功")
|
||||
}
|
||||
|
||||
// SendBalanceMigrateEmail 发送积分转移邮件
|
||||
func (s *EmailServiceV1) SendBalanceMigrateEmail(ctx context.Context, toEmail, username string, balance int, token string) *utils.Response[any] {
|
||||
if s.config.SMTPUsername == "" || s.config.SMTPPassword == "" {
|
||||
logger.Logger.Error("邮件服务未配置")
|
||||
return utils.FailBusiness("邮件服务未配置")
|
||||
}
|
||||
|
||||
appName := brandAppName()
|
||||
subject := fmt.Sprintf("%s积分转移通知", appName)
|
||||
body, err := s.renderBalanceMigrateEmailTemplate(username, balance, token)
|
||||
if err != nil {
|
||||
logger.Logger.Error("邮件模板渲染失败")
|
||||
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
|
||||
}
|
||||
|
||||
err = s.sendEmail(ctx, toEmail, subject, body)
|
||||
if err != nil {
|
||||
logger.Logger.Error("邮件发送失败")
|
||||
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{
|
||||
@@ -150,44 +190,27 @@ func (s *EmailServiceV1) sendEmail(ctx context.Context, to, subject, body string
|
||||
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
|
||||
// parseTemplates 根据当前品牌解析 layout + content 模板
|
||||
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)
|
||||
}
|
||||
|
||||
// renderSignupEmailTemplate 渲染注册邮件模板
|
||||
func (s *EmailServiceV1) renderSignupEmailTemplate(username string, token string) (string, error) {
|
||||
// 单独解析注册确认模板
|
||||
tmpl, err := template.ParseFS(templateFiles, "templates/layout.gohtml", "templates/signup_confirm.gohtml")
|
||||
tmpl, err := parseTemplates("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)
|
||||
bc := GetBrandConfig()
|
||||
data := NewSignupTemplateData(username, bc.AppName, bc.AppCopyright, bc.EmailConfirmBaseUrl+token)
|
||||
|
||||
prefix := templatePrefix()
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.ExecuteTemplate(&buf, "signup_confirm.gohtml", data)
|
||||
err = tmpl.ExecuteTemplate(&buf, prefix+"signup_confirm.gohtml", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("渲染注册邮件模板失败: %v", err)
|
||||
}
|
||||
@@ -197,16 +220,17 @@ func (s *EmailServiceV1) renderSignupEmailTemplate(username string, token string
|
||||
|
||||
// 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")
|
||||
tmpl, err := parseTemplates("task_notification.gohtml")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("解析任务通知邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
data := NewTaskNotificationTemplateData(taskName, status, details, cst2.AppName, cst2.AppCopyright)
|
||||
bc := GetBrandConfig()
|
||||
data := NewTaskNotificationTemplateData(taskName, status, details, bc.AppName, bc.AppCopyright)
|
||||
|
||||
prefix := templatePrefix()
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.ExecuteTemplate(&buf, "task_notification.gohtml", data)
|
||||
err = tmpl.ExecuteTemplate(&buf, prefix+"task_notification.gohtml", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("渲染任务通知邮件模板失败: %v", err)
|
||||
}
|
||||
@@ -216,20 +240,17 @@ func (s *EmailServiceV1) renderTaskNotificationTemplate(taskName string, status
|
||||
|
||||
// renderResetPasswordEmailTemplate 渲染重置密码邮件模板
|
||||
func (s *EmailServiceV1) renderResetPasswordEmailTemplate(username string, token string) (string, error) {
|
||||
// 单独解析重置密码模板
|
||||
tmpl, err := template.ParseFS(templateFiles, "templates/layout.gohtml", "templates/reset_password.gohtml")
|
||||
tmpl, err := parseTemplates("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)
|
||||
bc := GetBrandConfig()
|
||||
data := NewResetPasswordTemplateData(username, bc.AppName, bc.AppCopyright, bc.EmailResetPasswordUrl+token)
|
||||
|
||||
prefix := templatePrefix()
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.ExecuteTemplate(&buf, "reset_password.gohtml", data)
|
||||
err = tmpl.ExecuteTemplate(&buf, prefix+"reset_password.gohtml", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("渲染重置密码邮件模板失败: %v", err)
|
||||
}
|
||||
@@ -237,45 +258,19 @@ func (s *EmailServiceV1) renderResetPasswordEmailTemplate(username string, token
|
||||
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("邮件服务未配置")
|
||||
return utils.FailBusiness("邮件服务未配置")
|
||||
}
|
||||
|
||||
subject := fmt.Sprintf("%s积分转移通知", cst2.AppName)
|
||||
body, err := s.renderBalanceMigrateEmailTemplate(username, balance, token)
|
||||
if err != nil {
|
||||
logger.Logger.Error("邮件模板渲染失败")
|
||||
return utils.FailBusiness(fmt.Sprintf("邮件模板渲染失败: %v", err))
|
||||
}
|
||||
|
||||
err = s.sendEmail(ctx, toEmail, subject, body)
|
||||
if err != nil {
|
||||
logger.Logger.Error("邮件发送失败")
|
||||
return utils.FailBusiness(fmt.Sprintf("邮件发送失败: %v", err))
|
||||
}
|
||||
|
||||
return utils.SuccessWithMsg("积分转移邮件发送成功")
|
||||
}
|
||||
|
||||
// renderBalanceMigrateEmailTemplate 渲染积分转移邮件模板
|
||||
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)
|
||||
err = tmpl.ExecuteTemplate(&buf, prefix+"balance_migrate.gohtml", data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("渲染积分转移邮件模板失败: %v", err)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user