优化用户相关功能 (#8)
* improve(user):优化注册功能提示 * improve(user):优化邮箱大小写重复问题 * improve(user):添加密码强度指示器 * fix(setting):修复系统设置功能里面的数字转换问题 * feat(email):优化邮件发送功能 * chore(email):移除无用配置和日志 * fix(user):修复重置密码功能 * chore(ui):调整轮询间隔 * chore(ui):添加充值提示 * chore(ui):优化用户注册功能 * chore(sql):补充数据库变更文件 * fix(email):优化邮箱校验 --------- Co-authored-by: qian <wuchengqian5@163.com>
This commit is contained in:
1 parent
cce79ebb28
commit
501726b831
42 files changed
+1168
-124
No files matched your search
@@ -93,6 +93,9 @@ type IAdminV1 interface {
|
||||
DataGovernanceExport(ctx context.Context, req *v1.DataGovernanceExportReq) (res *v1.DataGovernanceExportRes, err error)
|
||||
DataGovernanceDeletion(ctx context.Context, req *v1.DataGovernanceDeletionReq) (res *v1.DataGovernanceDeletionRes, err error)
|
||||
DataGovernanceCleanup(ctx context.Context, req *v1.DataGovernanceCleanupReq) (res *v1.DataGovernanceCleanupRes, err error)
|
||||
EmailSendLogList(ctx context.Context, req *v1.EmailSendLogListReq) (res *v1.EmailSendLogListRes, err error)
|
||||
EmailSendLogDetail(ctx context.Context, req *v1.EmailSendLogDetailReq) (res *v1.EmailSendLogDetailRes, err error)
|
||||
EmailVerifyCodeList(ctx context.Context, req *v1.EmailVerifyCodeListReq) (res *v1.EmailVerifyCodeListRes, err error)
|
||||
ErrorLogList(ctx context.Context, req *v1.ErrorLogListReq) (res *v1.ErrorLogListRes, err error)
|
||||
ErrorLogDetail(ctx context.Context, req *v1.ErrorLogDetailReq) (res *v1.ErrorLogDetailRes, err error)
|
||||
ErrorLogResolve(ctx context.Context, req *v1.ErrorLogResolveReq) (res *v1.ErrorLogResolveRes, err error)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
)
|
||||
|
||||
// === 邮件发送记录(ntf_send_log)===
|
||||
|
||||
type EmailSendLogListReq struct {
|
||||
g.Meta `path:"/email/send-logs" method:"get" mime:"json" tags:"管理后台-邮件" summary:"邮件发送记录列表"`
|
||||
Page int `json:"page" d:"1"`
|
||||
PageSize int `json:"page_size" d:"20"`
|
||||
Status string `json:"status" dc:"状态:sent/failed"`
|
||||
Recipient string `json:"recipient" dc:"收件人(模糊匹配)"`
|
||||
TemplateCode string `json:"template_code" dc:"模板编码"`
|
||||
StartDate string `json:"start_date" dc:"开始日期 YYYY-MM-DD"`
|
||||
EndDate string `json:"end_date" dc:"结束日期 YYYY-MM-DD"`
|
||||
}
|
||||
|
||||
type EmailSendLogListRes struct {
|
||||
List []map[string]any `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
type EmailSendLogDetailReq struct {
|
||||
g.Meta `path:"/email/send-logs/{id}" method:"get" mime:"json" tags:"管理后台-邮件" summary:"邮件发送记录详情"`
|
||||
Id int64 `json:"id" in:"path" v:"required|min:1"`
|
||||
}
|
||||
|
||||
type EmailSendLogDetailRes struct {
|
||||
Data map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
func (r *EmailSendLogDetailRes) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(r.Data)
|
||||
}
|
||||
|
||||
// === 验证码申请记录(sys_email_verify_codes)===
|
||||
|
||||
type EmailVerifyCodeListReq struct {
|
||||
g.Meta `path:"/email/verify-codes" method:"get" mime:"json" tags:"管理后台-邮件" summary:"验证码申请记录列表"`
|
||||
Page int `json:"page" d:"1"`
|
||||
PageSize int `json:"page_size" d:"20"`
|
||||
Email string `json:"email" dc:"邮箱(模糊匹配)"`
|
||||
Purpose string `json:"purpose" dc:"用途:register/reset_password/change_email"`
|
||||
Used string `json:"used" dc:"是否已使用:true/false"`
|
||||
StartDate string `json:"start_date" dc:"开始日期 YYYY-MM-DD"`
|
||||
EndDate string `json:"end_date" dc:"结束日期 YYYY-MM-DD"`
|
||||
}
|
||||
|
||||
type EmailVerifyCodeListRes struct {
|
||||
List []map[string]any `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
@@ -48,6 +48,7 @@ type ITenantV1 interface {
|
||||
TenantSendCode(ctx context.Context, req *v1.TenantSendCodeReq) (res *v1.TenantSendCodeRes, err error)
|
||||
TenantResetPassword(ctx context.Context, req *v1.TenantResetPasswordReq) (res *v1.TenantResetPasswordRes, err error)
|
||||
TenantChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq) (res *v1.TenantChangeEmailRes, err error)
|
||||
TenantSendChangeEmailCode(ctx context.Context, req *v1.TenantSendChangeEmailCodeReq) (res *v1.TenantSendChangeEmailCodeRes, err error)
|
||||
FeedbackCreate(ctx context.Context, req *v1.FeedbackCreateReq) (res *v1.FeedbackCreateRes, err error)
|
||||
FeedbackList(ctx context.Context, req *v1.FeedbackListReq) (res *v1.FeedbackListRes, err error)
|
||||
FeedbackGet(ctx context.Context, req *v1.FeedbackGetReq) (res *v1.FeedbackGetRes, err error)
|
||||
|
||||
+13
-3
@@ -4,9 +4,11 @@ import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// TenantSendCodeReq 发送验证码请求
|
||||
type TenantSendCodeReq struct {
|
||||
g.Meta `path:"/email/send-code" method:"post" mime:"json" tags:"租户控制台-邮箱" summary:"发送验证码" group:"public" middleware:"-"`
|
||||
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
|
||||
Purpose string `json:"purpose" v:"required|in:register,reset_password,change_email#请选择用途|用途无效" dc:"用途:register / reset_password / change_email"`
|
||||
g.Meta `path:"/email/send-code" method:"post" mime:"json" tags:"租户控制台-邮箱" summary:"发送验证码" group:"public" middleware:"-"`
|
||||
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
|
||||
Purpose string `json:"purpose" v:"required|in:register,reset_password,change_email#请选择用途|用途无效" dc:"用途:register / reset_password / change_email"`
|
||||
CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(找回密码用途必填)"`
|
||||
CaptchaX int `json:"captcha_x" dc:"滑块X坐标(找回密码用途必填)"`
|
||||
}
|
||||
|
||||
type TenantSendCodeRes struct{}
|
||||
@@ -31,3 +33,11 @@ type TenantChangeEmailReq struct {
|
||||
}
|
||||
|
||||
type TenantChangeEmailRes struct{}
|
||||
|
||||
// TenantSendChangeEmailCodeReq 设置/修改邮箱时发送验证码(需登录,发送前校验租户内邮箱唯一性)
|
||||
type TenantSendChangeEmailCodeReq struct {
|
||||
g.Meta `path:"/email/send-change-email-code" method:"post" mime:"json" tags:"租户控制台-邮箱" summary:"发送设置邮箱验证码"`
|
||||
NewEmail string `json:"new_email" v:"required|email#请输入新邮箱|邮箱格式不正确" dc:"新邮箱"`
|
||||
}
|
||||
|
||||
type TenantSendChangeEmailCodeRes struct{}
|
||||
@@ -98,7 +98,7 @@ type TenantMemberJoinReq struct {
|
||||
Code string `json:"code" v:"required#请提供邀请码" dc:"邀请码"`
|
||||
Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"`
|
||||
Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"`
|
||||
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
|
||||
Email string `json:"email" v:"email#邮箱格式不正确" dc:"邮箱"`
|
||||
DisplayName string `json:"display_name" dc:"显示名称"`
|
||||
}
|
||||
|
||||
|
||||
@@ -485,3 +485,23 @@ Events []string `json:"events" v:"required|foreach|length:1,100#请选择事件|
|
||||
```
|
||||
**注意**:GoFrame v2 没有内置规则直接校验 slice 的元素个数。如需限制元素数量,需自定义校验规则或在 logic 层手动检查。
|
||||
|
||||
### 2026-06-16:gerror.NewCode 误用为格式化构造,错误消息占位符未替换
|
||||
|
||||
**问题描述**:注册时命中禁用词,返回的错误消息未格式化,用户看到 `%s包含禁用词「%s」,请修改后重试, 用户名, test`——`%s` 原样输出,参数被用 `, ` 拼接在末尾。
|
||||
|
||||
**原因**:`gerror.NewCode(code gcode.Code, text ...string)` 的第二及后续参数是**字面文本**(`...string`),不是 `format + args`。代码误把含 `%s` 占位符的模板连同 `fieldName`、`word` 一起传入,GoFrame 把它们当作多段 text 用 `, ` 拼接,模板原样保留、占位符不替换。带格式化的对应方法是 `gerror.NewCodef(code, format, args...)`。
|
||||
|
||||
```go
|
||||
// 错误 — NewCode 的参数是字面 text(...string),%s 不会被替换,多余参数被拼到末尾
|
||||
return gerror.NewCode(gcode.New(...), "%s包含禁用词「%s」,请修改后重试", fieldName, word)
|
||||
// 输出:%s包含禁用词「%s」,请修改后重试, 用户名, test
|
||||
|
||||
// 正确 — 用 NewCodef 做格式化
|
||||
return gerror.NewCodef(gcode.New(...), "%s包含禁用词「%s」,请修改后重试", fieldName, word)
|
||||
// 输出:用户名包含禁用词「test」,请修改后重试
|
||||
```
|
||||
|
||||
**修复方式**:`internal/logic/common/validation.go` 中 `ValidateForbiddenWords` 的 `gerror.NewCode` 改为 `gerror.NewCodef`。
|
||||
|
||||
**正确做法**:构造错误时区分 `NewXxx`(字面文本)与 `NewXxxf`(格式化)两个系列。凡消息含 `%s`/`%v`/`%d` 等占位符且需填入变量,必须用 `f` 变体(`gerror.Newf`、`gerror.NewCodef`、`gerror.NewWrapf`、`gerror.NewSkipf`);把格式串当字面文本传入 `NewXxx` 系列,占位符不会替换,多余实参还会被拼接到消息末尾。
|
||||
|
||||
@@ -177,6 +177,7 @@ const (
|
||||
CodeIpRateLimitExceeded = 10090 // IP级别限流
|
||||
CodeGlobalRateLimitExceeded = 10091 // 全局限流
|
||||
CodeForbiddenWord = 10096 // 名称包含禁用词
|
||||
CodeEmailExists = 10097 // 邮箱已被使用
|
||||
|
||||
// Setup errors
|
||||
CodeSetupCompleted = 10078
|
||||
@@ -253,6 +254,7 @@ const (
|
||||
MsgRateLimitExceeded = "请求频率超限"
|
||||
MsgPasswordTooWeak = "密码不符合策略"
|
||||
MsgUsernameExists = "用户名已存在"
|
||||
MsgEmailExists = "该邮箱已被使用"
|
||||
MsgTenantCodeExists = "租户代码已存在"
|
||||
MsgSessionExpired = "会话已过期"
|
||||
MsgTokenExpired = "Token 已过期"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
)
|
||||
|
||||
func (c *ControllerV1) EmailSendLogList(ctx context.Context, req *v1.EmailSendLogListReq) (res *v1.EmailSendLogListRes, err error) {
|
||||
return service.Admin().ListEmailSendLogs(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) EmailSendLogDetail(ctx context.Context, req *v1.EmailSendLogDetailReq) (res *v1.EmailSendLogDetailRes, err error) {
|
||||
return service.Admin().GetEmailSendLogDetail(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) EmailVerifyCodeList(ctx context.Context, req *v1.EmailVerifyCodeListReq) (res *v1.EmailVerifyCodeListRes, err error) {
|
||||
return service.Admin().ListEmailVerifyCodes(ctx, req)
|
||||
}
|
||||
@@ -16,3 +16,6 @@ func (c *ControllerV1) TenantResetPassword(ctx context.Context, req *v1.TenantRe
|
||||
func (c *ControllerV1) TenantChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq) (res *v1.TenantChangeEmailRes, err error) {
|
||||
return service.Tenant().ChangeEmail(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) TenantSendChangeEmailCode(ctx context.Context, req *v1.TenantSendChangeEmailCodeReq) (res *v1.TenantSendChangeEmailCodeRes, err error) {
|
||||
return service.Tenant().SendChangeEmailCode(ctx, req)
|
||||
}
|
||||
@@ -71,6 +71,7 @@ func (s *sAdmin) ListUsers(ctx context.Context, req *v1.AdminUserListReq) (*v1.A
|
||||
// CreateUser creates a new admin user.
|
||||
func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v1.AdminUserCreateRes, error) {
|
||||
username := strings.TrimSpace(req.Username)
|
||||
email := strings.ToLower(strings.TrimSpace(req.Email))
|
||||
|
||||
// Validate username format
|
||||
if err := common.ValidateUsername(username); err != nil {
|
||||
@@ -87,15 +88,16 @@ func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v
|
||||
return nil, common.NewBusinessError(consts.CodeUsernameExists, consts.MsgUsernameExists)
|
||||
}
|
||||
|
||||
// Check email uniqueness if provided
|
||||
if req.Email != "" {
|
||||
// Check email uniqueness if provided (normalized to lowercase so that
|
||||
// e.g. "User@Example.com" and "user@example.com" collide as the same address).
|
||||
if email != "" {
|
||||
count, err = dao.SysAdminUsers.Ctx(ctx).
|
||||
Where("email", strings.TrimSpace(req.Email)).Count()
|
||||
Where("email", email).Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, common.NewBadRequestError("邮箱已被使用")
|
||||
return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,12 +124,18 @@ func (s *sAdmin) CreateUser(ctx context.Context, req *v1.AdminUserCreateReq) (*v
|
||||
result, err := dao.SysAdminUsers.Ctx(ctx).Data(do.SysAdminUsers{
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
Email: strings.TrimSpace(req.Email),
|
||||
Email: email,
|
||||
DisplayName: username,
|
||||
Role: role,
|
||||
Status: "active",
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
// Race condition: another request inserted a colliding username/email
|
||||
// between our pre-check and this insert. Translate the DB unique
|
||||
// violation into a friendly business error.
|
||||
if common.IsDuplicateKeyError(err) {
|
||||
return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -158,15 +166,15 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v
|
||||
data.DisplayName = *req.DisplayName
|
||||
}
|
||||
if req.Email != nil {
|
||||
email := strings.TrimSpace(*req.Email)
|
||||
// Check uniqueness
|
||||
email := strings.ToLower(strings.TrimSpace(*req.Email))
|
||||
// Check uniqueness (normalized to lowercase)
|
||||
count, err := dao.SysAdminUsers.Ctx(ctx).
|
||||
Where("email", email).Where("id != ?", req.Id).Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, common.NewBadRequestError("邮箱已被使用")
|
||||
return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
||||
}
|
||||
data.Email = email
|
||||
}
|
||||
@@ -179,6 +187,9 @@ func (s *sAdmin) UpdateUser(ctx context.Context, req *v1.AdminUserUpdateReq) (*v
|
||||
|
||||
_, err = dao.SysAdminUsers.Ctx(ctx).Where("id", req.Id).Update(data)
|
||||
if err != nil {
|
||||
if common.IsDuplicateKeyError(err) {
|
||||
return nil, common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/model/entity"
|
||||
)
|
||||
|
||||
// ListEmailSendLogs 分页查询邮件发送记录(ntf_send_log,主库)。
|
||||
// 列表不返回 body(HTML 正文较大),由详情接口返回完整记录。
|
||||
func (s *sAdmin) ListEmailSendLogs(ctx context.Context, req *v1.EmailSendLogListReq) (*v1.EmailSendLogListRes, error) {
|
||||
if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
var conditions []string
|
||||
var args []any
|
||||
if req.Status != "" {
|
||||
conditions = append(conditions, "status = ?")
|
||||
args = append(args, req.Status)
|
||||
}
|
||||
if req.Recipient != "" {
|
||||
conditions = append(conditions, "recipient LIKE ?")
|
||||
args = append(args, "%"+req.Recipient+"%")
|
||||
}
|
||||
if req.TemplateCode != "" {
|
||||
conditions = append(conditions, "template_code = ?")
|
||||
args = append(args, req.TemplateCode)
|
||||
}
|
||||
if req.StartDate != "" {
|
||||
conditions = append(conditions, "created_at >= ?")
|
||||
args = append(args, req.StartDate+" 00:00:00")
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
conditions = append(conditions, "created_at <= ?")
|
||||
args = append(args, req.EndDate+" 23:59:59")
|
||||
}
|
||||
where := strings.Join(conditions, " AND ")
|
||||
|
||||
countM := dao.NtfSendLog.Ctx(ctx).Safe()
|
||||
if where != "" {
|
||||
countM = countM.Where(where, args...)
|
||||
}
|
||||
total, err := countM.Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
return &v1.EmailSendLogListRes{List: []map[string]any{}, Total: 0, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
dataM := dao.NtfSendLog.Ctx(ctx).Safe().
|
||||
Fields("id, tenant_id, user_id, template_code, channel, recipient, subject, status, error_message, retry_count, sent_at, created_at").
|
||||
OrderDesc("created_at").
|
||||
Page(page, pageSize)
|
||||
if where != "" {
|
||||
dataM = dataM.Where(where, args...)
|
||||
}
|
||||
result, err := dataM.All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(result))
|
||||
for _, row := range result {
|
||||
m := make(map[string]any, len(row))
|
||||
for k, v := range row {
|
||||
m[k] = v.Val()
|
||||
}
|
||||
items = append(items, m)
|
||||
}
|
||||
|
||||
return &v1.EmailSendLogListRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
// GetEmailSendLogDetail 查询单条邮件发送记录详情(含 body 正文)。
|
||||
func (s *sAdmin) GetEmailSendLogDetail(ctx context.Context, req *v1.EmailSendLogDetailReq) (*v1.EmailSendLogDetailRes, error) {
|
||||
var record *entity.NtfSendLog
|
||||
err := dao.NtfSendLog.Ctx(ctx).Where("id", req.Id).Scan(&record)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if record == nil {
|
||||
return nil, common.NewNotFoundError("邮件发送记录")
|
||||
}
|
||||
b, _ := json.Marshal(record)
|
||||
var detail map[string]any
|
||||
_ = json.Unmarshal(b, &detail)
|
||||
return &v1.EmailSendLogDetailRes{Data: detail}, nil
|
||||
}
|
||||
|
||||
// ListEmailVerifyCodes 分页查询验证码申请记录(sys_email_verify_codes,主库)。
|
||||
func (s *sAdmin) ListEmailVerifyCodes(ctx context.Context, req *v1.EmailVerifyCodeListReq) (*v1.EmailVerifyCodeListRes, error) {
|
||||
if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
var conditions []string
|
||||
var args []any
|
||||
if req.Email != "" {
|
||||
conditions = append(conditions, "email LIKE ?")
|
||||
args = append(args, "%"+req.Email+"%")
|
||||
}
|
||||
if req.Purpose != "" {
|
||||
conditions = append(conditions, "purpose = ?")
|
||||
args = append(args, req.Purpose)
|
||||
}
|
||||
if req.Used == "true" {
|
||||
conditions = append(conditions, "used_at IS NOT NULL")
|
||||
} else if req.Used == "false" {
|
||||
conditions = append(conditions, "used_at IS NULL")
|
||||
}
|
||||
if req.StartDate != "" {
|
||||
conditions = append(conditions, "created_at >= ?")
|
||||
args = append(args, req.StartDate+" 00:00:00")
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
conditions = append(conditions, "created_at <= ?")
|
||||
args = append(args, req.EndDate+" 23:59:59")
|
||||
}
|
||||
where := strings.Join(conditions, " AND ")
|
||||
|
||||
countM := dao.SysEmailVerifyCodes.Ctx(ctx).Safe()
|
||||
if where != "" {
|
||||
countM = countM.Where(where, args...)
|
||||
}
|
||||
total, err := countM.Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total == 0 {
|
||||
return &v1.EmailVerifyCodeListRes{List: []map[string]any{}, Total: 0, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
|
||||
dataM := dao.SysEmailVerifyCodes.Ctx(ctx).Safe().
|
||||
Fields("id, email, code, purpose, expires_at, used_at, created_at").
|
||||
OrderDesc("created_at").
|
||||
Page(page, pageSize)
|
||||
if where != "" {
|
||||
dataM = dataM.Where(where, args...)
|
||||
}
|
||||
result, err := dataM.All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]map[string]any, 0, len(result))
|
||||
for _, row := range result {
|
||||
m := make(map[string]any, len(row))
|
||||
for k, v := range row {
|
||||
m[k] = v.Val()
|
||||
}
|
||||
items = append(items, m)
|
||||
}
|
||||
|
||||
return &v1.EmailVerifyCodeListRes{List: items, Total: total, Page: page, PageSize: pageSize}, nil
|
||||
}
|
||||
@@ -79,7 +79,7 @@ func CreateAdmin(ctx context.Context, username, password, displayName string) er
|
||||
Status: "active",
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
if isDuplicateKeyError(err) {
|
||||
if common.IsDuplicateKeyError(err) {
|
||||
return common.NewBusinessError(consts.CodeSetupCompleted, consts.MsgSetupCompleted)
|
||||
}
|
||||
return err
|
||||
@@ -112,14 +112,3 @@ func AutoInitAdmin(ctx context.Context) (bool, error) {
|
||||
g.Log().Info(ctx, "通过环境变量自动初始化管理员完成")
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isDuplicateKeyError checks if the error is a PostgreSQL unique constraint violation.
|
||||
// PostgreSQL error code 23505 = unique_violation.
|
||||
// NOTE: Uses string matching on the error message. Consider switching to
|
||||
// pq.Error type assertion if github.com/lib/pq becomes a direct dependency.
|
||||
func isDuplicateKeyError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "duplicate key")
|
||||
}
|
||||
@@ -205,6 +205,26 @@ func CheckCaptchaRequired(ctx context.Context, _ string, captchaKey string, capt
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsCaptchaVerified checks whether the slider captcha has been completed
|
||||
// (the verified flag is set by VerifyCaptcha during the slide). Unlike
|
||||
// CheckCaptchaRequired it does NOT consume the flag, so a subsequent
|
||||
// consuming check (e.g. at password reset) still passes. Used to gate
|
||||
// low-sensitivity actions like sending a reset code without burning the
|
||||
// one-shot verification before the final sensitive operation.
|
||||
func IsCaptchaVerified(ctx context.Context, captchaKey string, _ int) error {
|
||||
if captchaKey == "" {
|
||||
return NewBusinessError(consts.CodeCaptchaRequired, consts.MsgCaptchaRequired)
|
||||
}
|
||||
|
||||
verifiedKey := fmt.Sprintf("%s:%s", captchaVerifiedPrefix, captchaKey)
|
||||
result, err := g.Redis().Do(ctx, "GET", verifiedKey)
|
||||
if err != nil || result.IsNil() {
|
||||
// No verified flag → user never completed the slide (or it expired)
|
||||
return NewBusinessError(consts.CodeCaptchaRequired, consts.MsgCaptchaRequired)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCaptchaExpireSeconds(ctx context.Context) time.Duration {
|
||||
secs := Config().GetInt(ctx, "captcha_expire_seconds")
|
||||
if secs < 60 {
|
||||
|
||||
@@ -3,6 +3,7 @@ package common
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gcode"
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
@@ -56,3 +57,16 @@ func IgnoreScanNoRows(err error) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// IsDuplicateKeyError reports whether err is a PostgreSQL unique-constraint
|
||||
// violation (SQLSTATE 23505). Used to convert race-condition insert failures
|
||||
// into friendly business errors at the data layer's last line of defense.
|
||||
//
|
||||
// Uses string matching on the driver error message. Switch to a typed assertion
|
||||
// (pq.Error / pgconn.PgError) if a Postgres driver becomes a direct dependency.
|
||||
func IsDuplicateKeyError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(err.Error(), "23505") || strings.Contains(err.Error(), "duplicate key")
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func ValidateForbiddenWords(ctx context.Context, value, fieldName string) error
|
||||
continue
|
||||
}
|
||||
if strings.Contains(lowerVal, strings.ToLower(word)) {
|
||||
return gerror.NewCode(gcode.New(consts.CodeForbiddenWord, consts.MsgForbiddenWord, nil),
|
||||
return gerror.NewCodef(gcode.New(consts.CodeForbiddenWord, consts.MsgForbiddenWord, nil),
|
||||
"%s包含禁用词「%s」,请修改后重试", fieldName, word)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,11 @@ func SendVerifyCode(ctx context.Context, email string, purpose VerifyPurpose) er
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
g.Log().Errorf(context.Background(), "send verify code email panic: %v", r)
|
||||
}
|
||||
}()
|
||||
bgCtx := context.Background()
|
||||
emailCfg, err := EmailConfigFromOptions(bgCtx)
|
||||
if err != nil {
|
||||
|
||||
@@ -25,9 +25,28 @@ func (s *sTenant) SendCode(ctx context.Context, req *v1.TenantSendCodeReq) (*v1.
|
||||
return nil, common.NewBusinessError(consts.CodeEmailVerificationDisabled, consts.MsgEmailVerificationDisabled)
|
||||
}
|
||||
case common.VerifyPurposeResetPwd:
|
||||
// Check if user exists with this email
|
||||
// For tenant reset, we need to know which tenant - user provides email
|
||||
// We'll verify the email exists during the actual reset
|
||||
// Require slider captcha before sending a reset code. Non-consuming:
|
||||
// the verified flag is left intact for the consuming check at
|
||||
// ResetPassword. (register/change_email intentionally not gated here.)
|
||||
if err := common.IsCaptchaVerified(ctx, req.CaptchaKey, req.CaptchaX); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 仅主用户(owner)可通过邮箱重置密码;校验邮箱是否属于某个 owner 账号。
|
||||
// 放在滑块验证之后,避免未通过人机验证就枚举哪些邮箱是主账号。
|
||||
var owner *entity.TntUsers
|
||||
err := dao.TntUsers.Ctx(ctx).
|
||||
Where("email", email).
|
||||
Where("role", "owner").
|
||||
Scan(&owner)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if owner == nil {
|
||||
return nil, common.NewBadRequestError("该邮箱未注册或非主账号,无法通过邮箱重置密码")
|
||||
}
|
||||
if owner.Status != "active" {
|
||||
return nil, common.NewBadRequestError("账号状态异常,请联系管理员")
|
||||
}
|
||||
case common.VerifyPurposeChangeEmail:
|
||||
// Check if new email is already taken by another user in same tenant
|
||||
// Tenant context should be available
|
||||
@@ -57,16 +76,18 @@ func (s *sTenant) ResetPassword(ctx context.Context, req *v1.TenantResetPassword
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find user by email (search across all tenants)
|
||||
// Find owner (主用户) by email — only owners may reset by email.
|
||||
// Members are managed by the owner/admin and do not self-reset via email.
|
||||
var user *entity.TntUsers
|
||||
err = dao.TntUsers.Ctx(ctx).
|
||||
Where("email", email).
|
||||
Where("role", "owner").
|
||||
Scan(&user)
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, common.NewBadRequestError("该邮箱未注册")
|
||||
return nil, common.NewBadRequestError("该邮箱未注册或非主账号,无法重置密码")
|
||||
}
|
||||
|
||||
if user.Status != "active" {
|
||||
@@ -107,6 +128,11 @@ func (s *sTenant) ChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq)
|
||||
userID := middleware.GetUserID(ctx)
|
||||
newEmail := strings.TrimSpace(strings.ToLower(req.NewEmail))
|
||||
|
||||
// Reject no-op: block if the new email equals the user's current email.
|
||||
if err := rejectSameEmail(ctx, userID, newEmail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify code for new email
|
||||
err := common.VerifyCode(ctx, newEmail, req.Code, "change_email")
|
||||
if err != nil {
|
||||
@@ -178,3 +204,58 @@ func (s *sTenant) ChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SendChangeEmailCode sends a verification code for setting/changing email.
|
||||
// Authenticated (unlike the public send-code): pre-checks that newEmail is not
|
||||
// already taken by another member in the same tenant before sending, so we don't
|
||||
// waste a code (and an email) on an address the user can't actually use.
|
||||
func (s *sTenant) SendChangeEmailCode(ctx context.Context, req *v1.TenantSendChangeEmailCodeReq) (*v1.TenantSendChangeEmailCodeRes, error) {
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
userID := middleware.GetUserID(ctx)
|
||||
newEmail := strings.TrimSpace(strings.ToLower(req.NewEmail))
|
||||
|
||||
// Reject no-op: block if the new email equals the user's current email.
|
||||
if err := rejectSameEmail(ctx, userID, newEmail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Pre-check: reject early if the email is already used by another member in this tenant.
|
||||
count, err := dao.TntUsers.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("email", newEmail).
|
||||
Where("id <> ?", userID).
|
||||
Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, common.NewBadRequestError("该邮箱已被其他成员使用")
|
||||
}
|
||||
|
||||
if err := common.SendVerifyCode(ctx, newEmail, common.VerifyPurposeChangeEmail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// rejectSameEmail blocks a no-op email change: if newEmail equals the user's
|
||||
// current email there's nothing to do, so refuse before sending a code (and a
|
||||
// pointless notification email) or performing an identical update. A user with
|
||||
// no email yet (NULL/empty) may still set any valid address.
|
||||
func rejectSameEmail(ctx context.Context, userID int64, newEmail string) error {
|
||||
if newEmail == "" {
|
||||
return nil
|
||||
}
|
||||
var me struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := dao.TntUsers.Ctx(ctx).Where("id", userID).Fields("email").Scan(&me); err != nil {
|
||||
if err = common.IgnoreScanNoRows(err); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if me.Email != "" && me.Email == newEmail {
|
||||
return common.NewBadRequestError("新邮箱与当前邮箱一致,无需修改")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -177,6 +177,16 @@ func buildInviteURL(ctx context.Context, code string) string {
|
||||
return fmt.Sprintf("%s/#/tenant/join?code=%s", base, code)
|
||||
}
|
||||
|
||||
// nullableEmail converts an empty email to nil so an optional email is stored as
|
||||
// SQL NULL (tnt_users.email is nullable) rather than "" — the latter would occupy
|
||||
// the UNIQUE(tenant_id, email) slot and block other email-less members in the tenant.
|
||||
func nullableEmail(email string) any {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
return email
|
||||
}
|
||||
|
||||
// JoinByInvite handles a user joining a tenant via invitation link.
|
||||
func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq) (*v1.TenantMemberJoinRes, error) {
|
||||
// Find invitation
|
||||
@@ -268,16 +278,18 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
|
||||
return common.NewBusinessError(consts.CodeUsernameExists, consts.MsgUsernameExists)
|
||||
}
|
||||
|
||||
// Check email uniqueness within tenant
|
||||
count, err = tx.Model("tnt_users").Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("email", email).
|
||||
Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return common.NewBadRequestError("该邮箱已被使用")
|
||||
// Check email uniqueness within tenant (email is optional — skip when empty)
|
||||
if email != "" {
|
||||
count, err = tx.Model("tnt_users").Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("email", email).
|
||||
Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
||||
}
|
||||
}
|
||||
|
||||
// Create user
|
||||
@@ -289,13 +301,18 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
|
||||
userResult, err := tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{
|
||||
TenantId: tenantID,
|
||||
Username: username,
|
||||
Email: email,
|
||||
Email: nullableEmail(email),
|
||||
PasswordHash: passwordHash,
|
||||
DisplayName: displayName,
|
||||
Role: invitation.Role,
|
||||
Status: "active",
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
// Race condition: another request inserted a colliding
|
||||
// username/email between our pre-check and this insert.
|
||||
if common.IsDuplicateKeyError(err) {
|
||||
return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
||||
}
|
||||
return gerror.Wrapf(err, "创建用户失败")
|
||||
}
|
||||
userID, err = userResult.LastInsertId()
|
||||
@@ -441,7 +458,7 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return common.NewBadRequestError("该邮箱已被使用")
|
||||
return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,13 +466,18 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe
|
||||
userResult, err := tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{
|
||||
TenantId: tenantID,
|
||||
Username: username,
|
||||
Email: email,
|
||||
Email: nullableEmail(email),
|
||||
PasswordHash: passwordHash,
|
||||
DisplayName: displayName,
|
||||
Role: req.Role,
|
||||
Status: "active",
|
||||
}).Insert()
|
||||
if err != nil {
|
||||
// Race condition: another request inserted a colliding
|
||||
// username/email between our pre-check and this insert.
|
||||
if common.IsDuplicateKeyError(err) {
|
||||
return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
||||
}
|
||||
return gerror.Wrapf(err, "创建用户失败")
|
||||
}
|
||||
userID, err = userResult.LastInsertId()
|
||||
|
||||
@@ -178,6 +178,13 @@ type (
|
||||
DataGovernanceDeletion(ctx context.Context, req *v1.DataGovernanceDeletionReq) (*v1.DataGovernanceDeletionRes, error)
|
||||
// TriggerDataCleanup 手动触发数据清理
|
||||
DataGovernanceCleanup(ctx context.Context, _ *v1.DataGovernanceCleanupReq) (*v1.DataGovernanceCleanupRes, error)
|
||||
// ListEmailSendLogs 分页查询邮件发送记录(ntf_send_log,主库)。
|
||||
// 列表不返回 body(HTML 正文较大),由详情接口返回完整记录。
|
||||
ListEmailSendLogs(ctx context.Context, req *v1.EmailSendLogListReq) (*v1.EmailSendLogListRes, error)
|
||||
// GetEmailSendLogDetail 查询单条邮件发送记录详情(含 body 正文)。
|
||||
GetEmailSendLogDetail(ctx context.Context, req *v1.EmailSendLogDetailReq) (*v1.EmailSendLogDetailRes, error)
|
||||
// ListEmailVerifyCodes 分页查询验证码申请记录(sys_email_verify_codes,主库)。
|
||||
ListEmailVerifyCodes(ctx context.Context, req *v1.EmailVerifyCodeListReq) (*v1.EmailVerifyCodeListRes, error)
|
||||
// ErrorLogList returns a paginated list of system error logs.
|
||||
ErrorLogList(ctx context.Context, req *v1.ErrorLogListReq) (*v1.ErrorLogListRes, error)
|
||||
// ErrorLogDetail returns the detail of a single error log.
|
||||
|
||||
@@ -94,6 +94,11 @@ type (
|
||||
ResetPassword(ctx context.Context, req *v1.TenantResetPasswordReq) (*v1.TenantResetPasswordRes, error)
|
||||
// ChangeEmail handles email change for a tenant user.
|
||||
ChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq) (*v1.TenantChangeEmailRes, error)
|
||||
// SendChangeEmailCode sends a verification code for setting/changing email.
|
||||
// Authenticated (unlike the public send-code): pre-checks that newEmail is not
|
||||
// already taken by another member in the same tenant before sending, so we don't
|
||||
// waste a code (and an email) on an address the user can't actually use.
|
||||
SendChangeEmailCode(ctx context.Context, req *v1.TenantSendChangeEmailCodeReq) (*v1.TenantSendChangeEmailCodeRes, error)
|
||||
// CreateFeedback 提交反馈
|
||||
CreateFeedback(ctx context.Context, req *v1.FeedbackCreateReq) (*v1.FeedbackCreateRes, error)
|
||||
// ListFeedbacks 我的反馈列表
|
||||
|
||||
@@ -56,14 +56,6 @@ jwt:
|
||||
crypto:
|
||||
encryptionKey: "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
|
||||
|
||||
# Email SMTP (fallback defaults, override via opt_options or env vars)
|
||||
email:
|
||||
smtp:
|
||||
host: ""
|
||||
port: 587
|
||||
username: ""
|
||||
password: ""
|
||||
from: ""
|
||||
|
||||
# Demo mode (read-only mode, configured here — not in admin UI)
|
||||
# Set enabled: true to block all write operations and show a watermark
|
||||
|
||||
@@ -73,14 +73,6 @@ jwt:
|
||||
crypto:
|
||||
encryptionKey: "请修改:运行 openssl rand -hex 32 生成 64 位十六进制密钥" # 请修改
|
||||
|
||||
# 邮件 SMTP 配置(可选,用于邮箱验证码等功能)
|
||||
email:
|
||||
smtp:
|
||||
host: ""
|
||||
port: 587
|
||||
username: ""
|
||||
password: ""
|
||||
from: ""
|
||||
|
||||
# 演示模式(通过配置文件控制,不在管理后台设置)
|
||||
# 开启后所有写操作被拦截,仅允许只读访问,并在控制台显示水印
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
-- +goose Up
|
||||
-- 将 tnt_users.email 由 NOT NULL 改为可空。
|
||||
--
|
||||
-- 背景:普通成员的邮箱在系统中基本用不上——成员只能用 RAM 账号(username@tenant_code)
|
||||
-- 登录(admin 邮箱登录只认 role=owner 的主用户),且成员不能凭邮箱自助重置密码,
|
||||
-- 邮箱仅用于邮件通知与成员列表展示。因此将邀请加入、管理员创建、CSV 导入三条
|
||||
-- 创建路径统一为「邮箱选填」,与 CreateMember / member_import 既有的选填行为对齐。
|
||||
--
|
||||
-- 顺带修复既有隐患:原 email 为 NOT NULL + UNIQUE(tenant_id, email),不填邮箱时
|
||||
-- 代码只能写入空字符串 "",而空字符串是确定值,导致同一租户下仅能存在一个空邮箱
|
||||
-- 成员,第二个会撞唯一约束报「邮箱已存在」。改为可空后,PostgreSQL 唯一索引允许
|
||||
-- 多个 NULL,多个无邮箱成员不再冲突。
|
||||
--
|
||||
-- 注意:列的可空性变更不需要重新 gf gen dao——DO 中 Email 字段本就声明为 any 类型,
|
||||
-- 业务代码在 email 为空时传 nil 即可写入 SQL NULL。
|
||||
|
||||
ALTER TABLE tnt_users ALTER COLUMN email DROP NOT NULL;
|
||||
|
||||
-- 将已存在的空字符串 email 归一为 NULL,避免旧空串数据继续占用唯一约束位。
|
||||
UPDATE tnt_users SET email = NULL WHERE email = '';
|
||||
|
||||
-- +goose Down
|
||||
-- 恢复 NOT NULL 前需保证无 NULL email(否则 SET NOT NULL 会失败),回退为空字符串。
|
||||
UPDATE tnt_users SET email = '' WHERE email IS NULL;
|
||||
ALTER TABLE tnt_users ALTER COLUMN email SET NOT NULL;
|
||||
@@ -186,6 +186,12 @@ const adminRoutes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/operation/MessagesPage.vue'),
|
||||
meta: { title: '消息管理' },
|
||||
},
|
||||
{
|
||||
path: 'email-logs',
|
||||
name: 'AdminEmailLogs',
|
||||
component: () => import('@/views/operation/EmailLogsPage.vue'),
|
||||
meta: { title: '邮件发送记录' },
|
||||
},
|
||||
{
|
||||
path: 'announcements',
|
||||
name: 'AdminAnnouncements',
|
||||
|
||||
@@ -326,6 +326,7 @@ const menuGroups = [
|
||||
items: [
|
||||
{ name: 'AdminNotificationTemplates', label: '通知模板', icon: IconNotification },
|
||||
{ name: 'AdminMessages', label: '消息管理', icon: IconSend },
|
||||
{ name: 'AdminEmailLogs', label: '邮件发送记录', icon: IconFile },
|
||||
{ name: 'AdminAnnouncements', label: '公告管理', icon: IconMessage },
|
||||
{ name: 'AdminTickets', label: '工单管理', icon: IconCommand },
|
||||
{ name: 'AdminFeedback', label: '反馈管理', icon: IconMessage },
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, watch, h } from 'vue'
|
||||
import { Tag, Button } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const activeTab = ref<'send-logs' | 'verify-codes'>('send-logs')
|
||||
|
||||
// ====== Tab 1: 邮件发送记录(ntf_send_log)======
|
||||
const sendLoading = ref(false)
|
||||
const sendData = ref<any[]>([])
|
||||
const sendPagination = reactive({
|
||||
current: 1, pageSize: 20, total: 0,
|
||||
showPageSize: true, pageSizeOptions: [10, 20, 50],
|
||||
})
|
||||
const sendFilter = reactive({
|
||||
status: '',
|
||||
recipient: '',
|
||||
date_range: [] as string[],
|
||||
})
|
||||
|
||||
const statusColor: Record<string, string> = { sent: 'green', failed: 'red', pending: 'orange' }
|
||||
const statusLabel: Record<string, string> = { sent: '已发送', failed: '发送失败', pending: '待发送' }
|
||||
|
||||
const sendColumns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '收件人', dataIndex: 'recipient', width: 200, ellipsis: true },
|
||||
{ title: '主题', dataIndex: 'subject', width: 200, ellipsis: true },
|
||||
{ title: '模板', dataIndex: 'template_code', width: 120, ellipsis: true, render({ record }) { return record.template_code || '-' } },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 90,
|
||||
render({ record }) {
|
||||
return h(Tag, { color: statusColor[record.status] || 'gray', size: 'small' }, () => statusLabel[record.status] || record.status)
|
||||
},
|
||||
},
|
||||
{ title: '重试', dataIndex: 'retry_count', width: 60 },
|
||||
{
|
||||
title: '错误信息', dataIndex: 'error_message', width: 220, ellipsis: true,
|
||||
render({ record }) {
|
||||
return record.error_message ? h('span', { style: 'color:#f53f3f' }, record.error_message) : '-'
|
||||
},
|
||||
},
|
||||
{ title: '发送时间', dataIndex: 'sent_at', width: 170, render({ record }) { return record.sent_at || record.created_at } },
|
||||
{
|
||||
title: '操作', dataIndex: 'actions', width: 70, fixed: 'right',
|
||||
render({ record }) {
|
||||
return h(Button, { size: 'mini', type: 'text', onClick: () => fetchSendDetail(record.id) }, () => '详情')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function fetchSendData() {
|
||||
sendLoading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { page: sendPagination.current, page_size: sendPagination.pageSize }
|
||||
if (sendFilter.status) params.status = sendFilter.status
|
||||
if (sendFilter.recipient) params.recipient = sendFilter.recipient
|
||||
if (sendFilter.date_range.length === 2) {
|
||||
params.start_date = sendFilter.date_range[0]
|
||||
params.end_date = sendFilter.date_range[1]
|
||||
}
|
||||
const res: any = await request.get('/admin/email/send-logs', { params })
|
||||
const raw = res.data?.data
|
||||
sendData.value = (Array.isArray(raw?.list) ? raw.list : []).filter(Boolean)
|
||||
sendPagination.total = Number(raw?.total) || 0
|
||||
} catch {
|
||||
sendData.value = []
|
||||
sendPagination.total = 0
|
||||
} finally {
|
||||
sendLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSendFilter() { sendPagination.current = 1; fetchSendData() }
|
||||
function handleSendReset() {
|
||||
sendFilter.status = ''
|
||||
sendFilter.recipient = ''
|
||||
sendFilter.date_range = []
|
||||
sendPagination.current = 1
|
||||
fetchSendData()
|
||||
}
|
||||
|
||||
// 详情抽屉
|
||||
const detailLoading = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const detailRecord = ref<any>(null)
|
||||
async function fetchSendDetail(id: number) {
|
||||
detailLoading.value = true
|
||||
showDetail.value = true
|
||||
try {
|
||||
const res: any = await request.get(`/admin/email/send-logs/${id}`)
|
||||
detailRecord.value = res.data?.data || null
|
||||
} catch {
|
||||
detailRecord.value = null
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Tab 2: 验证码记录(sys_email_verify_codes)======
|
||||
const codeLoading = ref(false)
|
||||
const codeData = ref<any[]>([])
|
||||
const codePagination = reactive({
|
||||
current: 1, pageSize: 20, total: 0,
|
||||
showPageSize: true, pageSizeOptions: [10, 20, 50],
|
||||
})
|
||||
const codeFilter = reactive({ email: '', purpose: '', used: '' })
|
||||
|
||||
const purposeColor: Record<string, string> = { register: 'arcoblue', reset_password: 'orange', change_email: 'cyan' }
|
||||
const purposeLabel: Record<string, string> = { register: '注册', reset_password: '重置密码', change_email: '更换邮箱' }
|
||||
|
||||
const codeColumns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '邮箱', dataIndex: 'email', width: 220, ellipsis: true },
|
||||
{
|
||||
title: '验证码', dataIndex: 'code', width: 110,
|
||||
render({ record }) {
|
||||
return h('span', { style: 'font-family:monospace;letter-spacing:2px;font-weight:600;color:#165dff' }, record.code)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '用途', dataIndex: 'purpose', width: 100,
|
||||
render({ record }) {
|
||||
return h(Tag, { color: purposeColor[record.purpose] || 'gray', size: 'small' }, () => purposeLabel[record.purpose] || record.purpose)
|
||||
},
|
||||
},
|
||||
{ title: '有效期至', dataIndex: 'expires_at', width: 170 },
|
||||
{
|
||||
title: '是否已用', dataIndex: 'used_at', width: 100,
|
||||
render({ record }) {
|
||||
return h(Tag, { color: record.used_at ? 'green' : 'gray', size: 'small' }, () => record.used_at ? '已使用' : '未使用')
|
||||
},
|
||||
},
|
||||
{ title: '申请时间', dataIndex: 'created_at', width: 170 },
|
||||
]
|
||||
|
||||
async function fetchCodeData() {
|
||||
codeLoading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { page: codePagination.current, page_size: codePagination.pageSize }
|
||||
if (codeFilter.email) params.email = codeFilter.email
|
||||
if (codeFilter.purpose) params.purpose = codeFilter.purpose
|
||||
if (codeFilter.used) params.used = codeFilter.used
|
||||
const res: any = await request.get('/admin/email/verify-codes', { params })
|
||||
const raw = res.data?.data
|
||||
codeData.value = (Array.isArray(raw?.list) ? raw.list : []).filter(Boolean)
|
||||
codePagination.total = Number(raw?.total) || 0
|
||||
} catch {
|
||||
codeData.value = []
|
||||
codePagination.total = 0
|
||||
} finally {
|
||||
codeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleCodeFilter() { codePagination.current = 1; fetchCodeData() }
|
||||
function handleCodeReset() {
|
||||
codeFilter.email = ''
|
||||
codeFilter.purpose = ''
|
||||
codeFilter.used = ''
|
||||
codePagination.current = 1
|
||||
fetchCodeData()
|
||||
}
|
||||
|
||||
// 切 Tab 懒加载
|
||||
const loaded = reactive({ 'send-logs': false, 'verify-codes': false })
|
||||
watch(activeTab, (v) => {
|
||||
if (v === 'verify-codes' && !loaded['verify-codes']) {
|
||||
loaded['verify-codes'] = true
|
||||
fetchCodeData()
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loaded['send-logs'] = true
|
||||
fetchSendData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-table">
|
||||
<PageHeader title="邮件发送记录" description="查看邮件实际发送记录与验证码申请记录,排查邮件是否成功发出" />
|
||||
|
||||
<a-tabs v-model:active-key="activeTab">
|
||||
<!-- 邮件发送记录 -->
|
||||
<a-tab-pane key="send-logs" title="邮件发送记录">
|
||||
<a-card :bordered="false" class="mb-4">
|
||||
<a-space wrap>
|
||||
<a-select v-model="sendFilter.status" placeholder="状态" allow-clear style="width: 140px" @change="handleSendFilter">
|
||||
<a-option value="sent">已发送</a-option>
|
||||
<a-option value="failed">发送失败</a-option>
|
||||
</a-select>
|
||||
<a-input v-model="sendFilter.recipient" placeholder="收件人" allow-clear style="width: 220px" @keydown.enter="handleSendFilter" />
|
||||
<a-range-picker v-model="sendFilter.date_range" style="width: 260px" format="YYYY-MM-DD" />
|
||||
<a-button type="primary" @click="handleSendFilter">搜索</a-button>
|
||||
<a-button @click="handleSendReset">重置</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-table
|
||||
:columns="sendColumns"
|
||||
:data="sendData"
|
||||
:loading="sendLoading"
|
||||
:scroll="{ x: 1300 }"
|
||||
:bordered="false"
|
||||
:stripe="true"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<a-pagination
|
||||
v-model:current="sendPagination.current"
|
||||
v-model:page-size="sendPagination.pageSize"
|
||||
:total="sendPagination.total"
|
||||
:page-size-options="sendPagination.pageSizeOptions"
|
||||
show-page-size
|
||||
@change="fetchSendData"
|
||||
@page-size-change="(s: number) => { sendPagination.pageSize = s; sendPagination.current = 1; fetchSendData() }"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- 验证码记录 -->
|
||||
<a-tab-pane key="verify-codes" title="验证码记录">
|
||||
<a-card :bordered="false" class="mb-4">
|
||||
<a-space wrap>
|
||||
<a-input v-model="codeFilter.email" placeholder="邮箱" allow-clear style="width: 220px" @keydown.enter="handleCodeFilter" />
|
||||
<a-select v-model="codeFilter.purpose" placeholder="用途" allow-clear style="width: 140px" @change="handleCodeFilter">
|
||||
<a-option value="register">注册</a-option>
|
||||
<a-option value="reset_password">重置密码</a-option>
|
||||
<a-option value="change_email">更换邮箱</a-option>
|
||||
</a-select>
|
||||
<a-select v-model="codeFilter.used" placeholder="是否已用" allow-clear style="width: 140px" @change="handleCodeFilter">
|
||||
<a-option value="true">已使用</a-option>
|
||||
<a-option value="false">未使用</a-option>
|
||||
</a-select>
|
||||
<a-button type="primary" @click="handleCodeFilter">搜索</a-button>
|
||||
<a-button @click="handleCodeReset">重置</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-table
|
||||
:columns="codeColumns"
|
||||
:data="codeData"
|
||||
:loading="codeLoading"
|
||||
:scroll="{ x: 1000 }"
|
||||
:bordered="false"
|
||||
:stripe="true"
|
||||
size="small"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
/>
|
||||
<div class="table-footer">
|
||||
<a-pagination
|
||||
v-model:current="codePagination.current"
|
||||
v-model:page-size="codePagination.pageSize"
|
||||
:total="codePagination.total"
|
||||
:page-size-options="codePagination.pageSizeOptions"
|
||||
show-page-size
|
||||
@change="fetchCodeData"
|
||||
@page-size-change="(s: number) => { codePagination.pageSize = s; codePagination.current = 1; fetchCodeData() }"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<a-drawer v-model:visible="showDetail" :width="560" title="邮件发送详情">
|
||||
<a-spin :loading="detailLoading" class="w-full">
|
||||
<template v-if="detailRecord">
|
||||
<a-descriptions :column="1" bordered size="medium">
|
||||
<a-descriptions-item label="收件人">{{ detailRecord.recipient }}</a-descriptions-item>
|
||||
<a-descriptions-item label="主题">{{ detailRecord.subject }}</a-descriptions-item>
|
||||
<a-descriptions-item label="模板">{{ detailRecord.template_code || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<Tag :color="statusColor[detailRecord.status] || 'gray'" size="small">{{ statusLabel[detailRecord.status] || detailRecord.status }}</Tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="重试次数">{{ detailRecord.retry_count }}</a-descriptions-item>
|
||||
<a-descriptions-item label="发送时间">{{ detailRecord.sent_at || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="租户 / 用户">{{ detailRecord.tenant_id || '-' }} / {{ detailRecord.user_id || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="detailRecord.error_message" label="错误信息">
|
||||
<span style="color: #f53f3f">{{ detailRecord.error_message }}</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div v-if="detailRecord.body" class="mt-4">
|
||||
<h4 style="margin-bottom: 8px; color: var(--ta-text-primary)">邮件正文</h4>
|
||||
<pre class="email-body">{{ detailRecord.body }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border-light, #e5e6eb);
|
||||
}
|
||||
.email-body {
|
||||
background: var(--color-fill-2, #f7f8fa);
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
max-height: 400px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -27,15 +27,15 @@ const values = useFormValues()
|
||||
<div class="section-grid">
|
||||
<AFormItem label="审计日志保留天数" class="field-full">
|
||||
<AInputNumber
|
||||
:model-value="values['audit_retention_days'] as number"
|
||||
@change="(v: number | undefined) => values['audit_retention_days'] = String(v ?? 90)"
|
||||
:model-value="Number(values['audit_retention_days']) || undefined"
|
||||
@change="(v: number | undefined) => values['audit_retention_days'] = v"
|
||||
:min="7" :max="3650" style="width: 100%"
|
||||
/>
|
||||
</AFormItem>
|
||||
<AFormItem label="操作日志保留天数" class="field-full">
|
||||
<AInputNumber
|
||||
:model-value="values['operation_log_retention_days'] as number"
|
||||
@change="(v: number | undefined) => values['operation_log_retention_days'] = String(v ?? 365)"
|
||||
:model-value="Number(values['operation_log_retention_days']) || undefined"
|
||||
@change="(v: number | undefined) => values['operation_log_retention_days'] = v"
|
||||
:min="30" :max="3650" style="width: 100%"
|
||||
/>
|
||||
</AFormItem>
|
||||
|
||||
@@ -14,9 +14,10 @@ const values = useFormValues()
|
||||
</AFormItem>
|
||||
<AFormItem label="SMTP 端口">
|
||||
<AInputNumber
|
||||
:model-value="values['email_smtp_port'] as number"
|
||||
@change="(v: number | undefined) => values['email_smtp_port'] = String(v ?? 587)"
|
||||
:model-value="Number(values['email_smtp_port']) || undefined"
|
||||
@change="(v: number | undefined) => values['email_smtp_port'] = v"
|
||||
:min="1" :max="65535"
|
||||
placeholder="587"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</AFormItem>
|
||||
|
||||
@@ -31,8 +31,8 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') {
|
||||
</AFormItem>
|
||||
<AFormItem label="最低充值金额">
|
||||
<AInputNumber
|
||||
:model-value="values['payment_min_topup'] as number"
|
||||
@change="(v: number | undefined) => values['payment_min_topup'] = String(v ?? 1)"
|
||||
:model-value="Number(values['payment_min_topup']) || undefined"
|
||||
@change="(v: number | undefined) => values['payment_min_topup'] = v"
|
||||
:min="1" style="width: 100%"
|
||||
/>
|
||||
</AFormItem>
|
||||
|
||||
@@ -169,12 +169,12 @@ const columns: TableColumnData[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '钱包余额',
|
||||
title: '钱包余额(美元)',
|
||||
dataIndex: 'wallet_balance',
|
||||
width: 120,
|
||||
render({ record }) {
|
||||
const val = parseFloat(record.wallet_balance || '0')
|
||||
return `¥${val.toFixed(2)}`
|
||||
return `\$${val.toFixed(2)}`
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||
|
||||
@@ -9,7 +9,7 @@ let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
timer = setInterval(() => fetchSettings(), 60_000)
|
||||
timer = setInterval(() => fetchSettings(), 30 * 60 * 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
password?: string
|
||||
}>()
|
||||
|
||||
// 强度档位:弱 / 中等 / 强 / 很强,颜色遵循租户端设计规范语义色
|
||||
const levels = [
|
||||
{ label: '弱', bar: 'bg-red-500', text: 'text-red-500' },
|
||||
{ label: '中等', bar: 'bg-amber-500', text: 'text-amber-600' },
|
||||
{ label: '强', bar: 'bg-primary-500', text: 'text-primary-600' },
|
||||
{ label: '很强', bar: 'bg-emerald-500', text: 'text-emerald-600' },
|
||||
] as const
|
||||
|
||||
// 评分规则:长度 + 字符多样性,映射到 1~4 档;空密码返回 0(不渲染)
|
||||
const score = computed(() => {
|
||||
const pw = props.password ?? ''
|
||||
if (!pw) return 0
|
||||
|
||||
let s = 0
|
||||
if (pw.length >= 8) s++
|
||||
if (pw.length >= 12) s++
|
||||
if (pw.length >= 16) s++
|
||||
if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) s++
|
||||
if (/\d/.test(pw)) s++
|
||||
if (/[^a-zA-Z0-9]/.test(pw)) s++
|
||||
|
||||
if (s <= 1) return 1
|
||||
if (s <= 3) return 2
|
||||
if (s <= 4) return 3
|
||||
return 4
|
||||
})
|
||||
|
||||
const current = computed(() => levels[score.value - 1])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="score > 0" class="mt-1.5 flex items-center gap-2">
|
||||
<div class="flex flex-1 gap-1">
|
||||
<div
|
||||
v-for="i in 4"
|
||||
:key="i"
|
||||
class="h-1 flex-1 rounded-full transition-colors duration-200"
|
||||
:class="i <= score ? current.bar : 'bg-gray-200'"
|
||||
/>
|
||||
</div>
|
||||
<span class="w-8 text-right text-xs font-medium" :class="current.text">
|
||||
{{ current.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,6 +14,10 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: { captchaKey: string; captchaX: number }]
|
||||
// Emits true only after a successful server verify; lets parents
|
||||
// enable/disable submit buttons reactively. (The captcha key is set on
|
||||
// image load, so the key alone cannot signal a completed slide.)
|
||||
'verified': [value: boolean]
|
||||
}>()
|
||||
|
||||
const { captchaData, loading, error, status, fetchCaptcha, verify, resetCaptcha } = useCaptcha()
|
||||
@@ -26,6 +30,12 @@ function handleReset() {
|
||||
}
|
||||
defineExpose({ resetCaptcha: handleReset, activate })
|
||||
|
||||
// Notify parent of verification state changes so it can reactively enable or
|
||||
// disable submit buttons. Also emits on mount/refresh when status resets.
|
||||
watch(status, (val) => {
|
||||
emit('verified', val === 'success')
|
||||
})
|
||||
|
||||
const triggerRef = ref<HTMLDivElement | null>(null)
|
||||
const popupRef = ref<HTMLDivElement | null>(null)
|
||||
const imageRef = ref<HTMLImageElement | null>(null)
|
||||
|
||||
@@ -77,7 +77,7 @@ async function fetchAnnouncements() {
|
||||
|
||||
onMounted(() => {
|
||||
fetchAnnouncements()
|
||||
timer = setInterval(fetchAnnouncements, 60_000)
|
||||
timer = setInterval(fetchAnnouncements, 30 * 60 * 1000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -16,7 +16,7 @@ interface ApiError {
|
||||
|
||||
/** Request config extension — set _suppressErrorMsg to skip auto error toast */
|
||||
declare module 'axios' {
|
||||
interface InternalAxiosRequestConfig {
|
||||
interface AxiosRequestConfig {
|
||||
_suppressErrorMsg?: boolean
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,17 @@ async function doRefresh(): Promise<TokenPair> {
|
||||
return newTokens
|
||||
}
|
||||
|
||||
const PUBLIC_PATHS = ['/settings/', '/captcha', '/captcha/', '/auth/login', '/auth/register', '/agreements/']
|
||||
const PUBLIC_PATHS = [
|
||||
'/settings/',
|
||||
'/captcha',
|
||||
'/captcha/',
|
||||
'/auth/login',
|
||||
'/auth/register',
|
||||
'/agreements/',
|
||||
// 找回密码流程无需登录态:发送验证码 / 重置密码
|
||||
'/email/send-code',
|
||||
'/email/reset-password',
|
||||
]
|
||||
|
||||
function isPublicPath(url?: string): boolean {
|
||||
if (!url) return false
|
||||
@@ -184,6 +194,7 @@ request.interceptors.response.use(
|
||||
}
|
||||
const err = new Error(msg)
|
||||
;(err as any).apiError = error.response.data as ApiError
|
||||
;(err as any).isBusinessError = true
|
||||
;(err as any).isDemoModeError = error.response.data.code === DEMO_MODE_CODE
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
@@ -194,11 +194,11 @@ onMounted(async () => {
|
||||
authStore.loadFromStorage()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
fetchAnnouncements()
|
||||
announcementTimer = setInterval(fetchAnnouncements, 10 * 60 * 1000)
|
||||
announcementTimer = setInterval(fetchAnnouncements, 30 * 60 * 1000)
|
||||
startNotificationPolling()
|
||||
if (canViewWallet.value) {
|
||||
fetchWalletBalance()
|
||||
walletTimer = setInterval(fetchWalletBalance, 60000)
|
||||
walletTimer = setInterval(fetchWalletBalance, 5 * 60 * 1000)
|
||||
} else {
|
||||
fetchMemberQuota()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import AuthLayout from '@/components/layout/AuthLayout.vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
|
||||
import PasswordStrengthMeter from '@/components/common/PasswordStrengthMeter.vue'
|
||||
import request, { extractApiError } from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -23,8 +24,19 @@ const form = reactive({
|
||||
|
||||
const errors = reactive<Record<string, string>>({})
|
||||
|
||||
// Captcha state
|
||||
const captcha = reactive({ captchaKey: '', captchaX: 0 })
|
||||
// Captcha state. Must be a ref: <SlideCaptcha v-model="captcha"> compiles to
|
||||
// `captcha = $event`, which would reassign a const reactive and silently drop
|
||||
// the value. With a ref Vue writes to captcha.value instead.
|
||||
const captcha = ref<{ captchaKey: string; captchaX: number }>({ captchaKey: '', captchaX: 0 })
|
||||
const captchaRef = ref<InstanceType<typeof SlideCaptcha> | null>(null)
|
||||
// Reactive verification flag (driven by SlideCaptcha's `verified` emit) so the
|
||||
// send button can be disabled until the slide is completed.
|
||||
const captchaVerified = ref(false)
|
||||
|
||||
// 10057 = 请完成滑块验证 (not completed), 10058 = 滑块验证失败 (failed/expired)
|
||||
function isCaptchaError(apiErr: any): boolean {
|
||||
return apiErr?.code === 10057 || apiErr?.code === 10058
|
||||
}
|
||||
|
||||
function clearErrors() {
|
||||
Object.keys(errors).forEach((k) => delete errors[k])
|
||||
@@ -41,17 +53,18 @@ async function sendCode() {
|
||||
await request.post('/tenant/email/send-code', {
|
||||
email: form.email,
|
||||
purpose: 'reset_password',
|
||||
captcha_key: captcha.captchaKey,
|
||||
captcha_x: captcha.captchaX,
|
||||
captcha_key: captcha.value.captchaKey,
|
||||
captcha_x: captcha.value.captchaX,
|
||||
})
|
||||
startCountdown()
|
||||
} catch (err: any) {
|
||||
const apiErr = extractApiError(err)
|
||||
if (apiErr?.code === 10058) {
|
||||
errors.email = '滑块验证失败,请重新拖动'
|
||||
if (isCaptchaError(apiErr)) {
|
||||
// 滑块验证已过期:引导用户返回第一步重新验证
|
||||
errors.code = apiErr?.message || '滑块验证已过期,请返回重新验证'
|
||||
return
|
||||
}
|
||||
errors.email = apiErr?.message || '发送验证码失败'
|
||||
errors.code = apiErr?.message || '发送验证码失败'
|
||||
} finally {
|
||||
codeSending.value = false
|
||||
}
|
||||
@@ -99,24 +112,31 @@ function validateStep2(): boolean {
|
||||
|
||||
async function goToStep2() {
|
||||
if (!validateStep1()) return
|
||||
// 必须先完成滑块验证才允许发送验证码(captchaKey 在图片加载后即非空,
|
||||
// 无法据此判断是否真的拖动过,改用 captchaVerified 判断服务端验证通过状态)
|
||||
if (!captchaVerified.value) {
|
||||
errors.captcha = '请先完成滑块验证'
|
||||
return
|
||||
}
|
||||
|
||||
codeSending.value = true
|
||||
try {
|
||||
await request.post('/tenant/email/send-code', {
|
||||
email: form.email,
|
||||
purpose: 'reset_password',
|
||||
captcha_key: captcha.captchaKey,
|
||||
captcha_x: captcha.captchaX,
|
||||
captcha_key: captcha.value.captchaKey,
|
||||
captcha_x: captcha.value.captchaX,
|
||||
})
|
||||
step.value = 2
|
||||
startCountdown()
|
||||
} catch (err: any) {
|
||||
const apiErr = extractApiError(err)
|
||||
if (apiErr?.code === 10058) {
|
||||
errors.email = '滑块验证失败,请重新拖动'
|
||||
if (isCaptchaError(apiErr)) {
|
||||
errors.captcha = apiErr?.message || '请重新完成滑块验证'
|
||||
captchaRef.value?.resetCaptcha()
|
||||
return
|
||||
}
|
||||
errors.email = apiErr?.message || '发送验证码失败'
|
||||
errors.captcha = apiErr?.message || '发送验证码失败'
|
||||
} finally {
|
||||
codeSending.value = false
|
||||
}
|
||||
@@ -131,14 +151,14 @@ async function handleReset() {
|
||||
email: form.email,
|
||||
code: form.code,
|
||||
password: form.newPassword,
|
||||
captcha_key: captcha.captchaKey,
|
||||
captcha_x: captcha.captchaX,
|
||||
captcha_key: captcha.value.captchaKey,
|
||||
captcha_x: captcha.value.captchaX,
|
||||
})
|
||||
router.push('/tenant/login')
|
||||
} catch (err: any) {
|
||||
const apiErr = extractApiError(err)
|
||||
if (apiErr?.code === 10058) {
|
||||
errors.code = '滑块验证失败,请重新拖动'
|
||||
if (isCaptchaError(apiErr)) {
|
||||
errors.code = apiErr?.message || '滑块验证已过期,请返回重新验证'
|
||||
return
|
||||
}
|
||||
errors.code = apiErr?.message || '重置密码失败'
|
||||
@@ -198,15 +218,18 @@ function goBack() {
|
||||
<p v-if="errors.email" class="input-error-text">{{ errors.email }}</p>
|
||||
</div>
|
||||
|
||||
<SlideCaptcha v-model="captcha" class="mb-4" />
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="codeSending"
|
||||
class="btn btn-primary btn-lg w-full"
|
||||
>
|
||||
<div v-if="codeSending" class="spinner h-4 w-4 border-white"></div>
|
||||
{{ codeSending ? '发送中...' : '发送重置验证码' }}
|
||||
</button>
|
||||
<SlideCaptcha ref="captchaRef" v-model="captcha" @verified="captchaVerified = $event" class="mb-4" />
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="codeSending || !captchaVerified"
|
||||
class="btn btn-primary btn-lg w-full"
|
||||
>
|
||||
<div v-if="codeSending" class="spinner h-4 w-4 border-white"></div>
|
||||
{{ codeSending ? '发送中...' : '发送重置验证码' }}
|
||||
</button>
|
||||
<p v-if="errors.captcha" class="input-error-text mt-2 text-center">{{ errors.captcha }}</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Step 2: Code + New Password -->
|
||||
@@ -243,6 +266,7 @@ function goBack() {
|
||||
class="input"
|
||||
:class="{ 'input-error': errors.newPassword }"
|
||||
/>
|
||||
<PasswordStrengthMeter :password="form.newPassword" />
|
||||
<p v-if="errors.newPassword" class="input-error-text">{{ errors.newPassword }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -65,22 +65,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">邮箱 <span class="text-red-500">*</span></label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5 text-gray-400">
|
||||
<Icon name="mail" size="sm" />
|
||||
</div>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="请输入邮箱"
|
||||
class="input pl-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">密码 <span class="text-red-500">*</span></label>
|
||||
<div class="relative">
|
||||
@@ -192,7 +176,6 @@ const joinResult = ref({
|
||||
const form = ref({
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
@@ -252,7 +235,6 @@ async function handleJoin() {
|
||||
code: route.query.code as string,
|
||||
username: form.value.username,
|
||||
display_name: form.value.display_name || undefined,
|
||||
email: form.value.email,
|
||||
password: form.value.password,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
|
||||
import Turnstile from '@/components/common/Turnstile.vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import AgreementViewModal from '@/components/common/AgreementViewModal.vue'
|
||||
import PasswordStrengthMeter from '@/components/common/PasswordStrengthMeter.vue'
|
||||
import request from '@/utils/request'
|
||||
import { extractApiError } from '@/utils/request'
|
||||
|
||||
@@ -403,6 +404,7 @@ async function handleRegister() {
|
||||
class="input"
|
||||
:class="{ 'input-error': userErrors.password }"
|
||||
/>
|
||||
<PasswordStrengthMeter :password="userForm.password" />
|
||||
<p v-if="userErrors.password" class="input-error-text">{{ userErrors.password }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -479,6 +479,14 @@ onBeforeUnmount(() => {
|
||||
充值 ¥{{ rechargeAmount || '—' }}
|
||||
</template>
|
||||
</button>
|
||||
|
||||
<!-- Conversion note -->
|
||||
<div class="recharge-note">
|
||||
<div class="recharge-note-icon">
|
||||
<Icon name="infoCircle" size="xs" />
|
||||
</div>
|
||||
<p>充值金额为人民币(¥),支付成功后将按平台汇率换算为美元($)入账到您的钱包余额。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -872,6 +880,24 @@ onBeforeUnmount(() => {
|
||||
.pay-method-icon-wxpay { background: linear-gradient(135deg, #07c160, #06ad56); }
|
||||
.pay-method-icon-default { background: linear-gradient(135deg, #6b7280, #4b5563); }
|
||||
|
||||
/* ==========================================
|
||||
Recharge Conversion Note
|
||||
========================================== */
|
||||
.recharge-note {
|
||||
display: flex; align-items: flex-start; gap: 0.5rem;
|
||||
padding: 0.75rem 0.875rem;
|
||||
border-radius: 0.75rem;
|
||||
background-color: #f0fdfa;
|
||||
border: 1px solid #ccfbf1;
|
||||
}
|
||||
.recharge-note-icon {
|
||||
flex-shrink: 0; margin-top: 0.0625rem;
|
||||
color: #14b8a6;
|
||||
}
|
||||
.recharge-note p {
|
||||
font-size: 0.75rem; line-height: 1.5; color: #0f766e;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Transitions
|
||||
========================================== */
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goToPlayground() {
|
||||
router.push({ name: 'TenantPlayground' })
|
||||
}
|
||||
|
||||
interface PricingTierItem {
|
||||
min_tokens: number
|
||||
max_tokens: number | null
|
||||
@@ -193,7 +200,14 @@ onMounted(fetchModels)
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">可用模型</h1>
|
||||
<p class="page-description">查看当前组织可使用的 AI 模型及其配置信息</p>
|
||||
<p class="page-description">
|
||||
查看当前组织可使用的 AI 模型及其配置信息。如果需要体验模型,请前往
|
||||
<a
|
||||
class="font-medium text-primary-600 hover:text-primary-700 underline underline-offset-2 transition-colors cursor-pointer"
|
||||
@click="goToPlayground"
|
||||
>在线体验</a>
|
||||
功能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import request from '@/utils/request'
|
||||
@@ -13,8 +13,6 @@ const { settings } = usePublicSettings()
|
||||
// OAuth state
|
||||
const oauthProviders = ref<any[]>([])
|
||||
const oauthLoading = ref(false)
|
||||
const linkProvider = ref('')
|
||||
const linkCode = ref('')
|
||||
const linkLoading = ref(false)
|
||||
const linkError = ref('')
|
||||
|
||||
@@ -33,6 +31,18 @@ const passwordSaving = ref(false)
|
||||
const passwordSuccess = ref(false)
|
||||
const showPasswordModal = ref(false)
|
||||
|
||||
// Email (set / change) modal state
|
||||
const showEmailModal = ref(false)
|
||||
const emailForm = reactive({
|
||||
new_email: '',
|
||||
code: '',
|
||||
})
|
||||
const emailSending = ref(false)
|
||||
const emailSaving = ref(false)
|
||||
const emailCooldown = ref(0)
|
||||
const emailError = ref('')
|
||||
let emailCooldownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const roleLabel: Record<string, string> = {
|
||||
owner: '所有者',
|
||||
admin: '管理员',
|
||||
@@ -129,6 +139,75 @@ async function handleChangePassword() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEmailModal() {
|
||||
emailForm.new_email = userInfo.value?.email || ''
|
||||
emailForm.code = ''
|
||||
emailError.value = ''
|
||||
emailCooldown.value = 0
|
||||
if (emailCooldownTimer) {
|
||||
clearInterval(emailCooldownTimer)
|
||||
emailCooldownTimer = null
|
||||
}
|
||||
showEmailModal.value = true
|
||||
}
|
||||
|
||||
async function sendEmailCode() {
|
||||
emailError.value = ''
|
||||
if (!emailForm.new_email.trim()) {
|
||||
emailError.value = '请输入邮箱'
|
||||
return
|
||||
}
|
||||
emailSending.value = true
|
||||
try {
|
||||
await request.post('/tenant/email/send-change-email-code', {
|
||||
new_email: emailForm.new_email,
|
||||
}, { _suppressErrorMsg: true })
|
||||
emailCooldown.value = 60
|
||||
emailCooldownTimer = setInterval(() => {
|
||||
emailCooldown.value--
|
||||
if (emailCooldown.value <= 0 && emailCooldownTimer) {
|
||||
clearInterval(emailCooldownTimer)
|
||||
emailCooldownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (e: any) {
|
||||
emailError.value = e?.message || '验证码发送失败'
|
||||
} finally {
|
||||
emailSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangeEmail() {
|
||||
emailError.value = ''
|
||||
if (!emailForm.new_email.trim()) {
|
||||
emailError.value = '请输入邮箱'
|
||||
return
|
||||
}
|
||||
if (!emailForm.code.trim()) {
|
||||
emailError.value = '请输入验证码'
|
||||
return
|
||||
}
|
||||
emailSaving.value = true
|
||||
try {
|
||||
await request.post('/tenant/email/change-email', {
|
||||
new_email: emailForm.new_email,
|
||||
code: emailForm.code,
|
||||
}, { _suppressErrorMsg: true })
|
||||
if (userInfo.value) {
|
||||
userInfo.value.email = emailForm.new_email
|
||||
}
|
||||
showEmailModal.value = false
|
||||
if (emailCooldownTimer) {
|
||||
clearInterval(emailCooldownTimer)
|
||||
emailCooldownTimer = null
|
||||
}
|
||||
} catch (e: any) {
|
||||
emailError.value = e?.message || '设置失败'
|
||||
} finally {
|
||||
emailSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSession(sessionId: number) {
|
||||
try {
|
||||
await request.delete(`/tenant/auth/sessions/${sessionId}`)
|
||||
@@ -152,6 +231,13 @@ onMounted(() => {
|
||||
fetchOAuthProviders()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (emailCooldownTimer) {
|
||||
clearInterval(emailCooldownTimer)
|
||||
emailCooldownTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchOAuthProviders() {
|
||||
oauthLoading.value = true
|
||||
try {
|
||||
@@ -181,7 +267,7 @@ async function handleOAuthLink(provider: string) {
|
||||
linkError.value = ''
|
||||
try {
|
||||
const res: any = await request.get('/tenant/oauth/authorize', { params: { provider } })
|
||||
const { authorize_url, state } = res.data?.data || {}
|
||||
const { authorize_url } = res.data?.data || {}
|
||||
if (authorize_url) {
|
||||
// Open OAuth in popup window
|
||||
const width = 600
|
||||
@@ -324,7 +410,16 @@ async function handleOAuthUnlink(provider: string) {
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<p class="input-label">邮箱</p>
|
||||
<span class="text-gray-900 font-medium">{{ userInfo.email || '--' }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-900 font-medium">{{ userInfo.email || '未设置' }}</span>
|
||||
<button
|
||||
@click="openEmailModal"
|
||||
class="text-primary-600 hover:text-primary-500 transition-colors"
|
||||
title="设置/修改邮箱"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
@@ -485,5 +580,54 @@ async function handleOAuthUnlink(provider: string) {
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<!-- Email Modal -->
|
||||
<BaseModal :show="showEmailModal" :title="userInfo?.email ? '修改邮箱' : '设置邮箱'" width="narrow" @close="showEmailModal = false">
|
||||
<form @submit.prevent="handleChangeEmail" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">新邮箱</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="emailForm.new_email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
class="input flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary whitespace-nowrap"
|
||||
:disabled="emailSending || emailCooldown > 0"
|
||||
@click="sendEmailCode"
|
||||
>
|
||||
{{ emailCooldown > 0 ? `${emailCooldown}s` : (emailSending ? '发送中...' : '发送验证码') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">验证码</label>
|
||||
<input
|
||||
v-model="emailForm.code"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="请输入6位验证码"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="emailError" class="input-error-text">{{ emailError }}</p>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="showEmailModal = false">取消</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="emailSaving"
|
||||
@click="handleChangeEmail"
|
||||
>
|
||||
{{ emailSaving ? '设置中...' : '确认' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user