diff --git a/api/admin/admin.go b/api/admin/admin.go
index 38b6432..e661019 100644
--- a/api/admin/admin.go
+++ b/api/admin/admin.go
@@ -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)
diff --git a/api/admin/v1/email_log.go b/api/admin/v1/email_log.go
new file mode 100644
index 0000000..07eb189
--- /dev/null
+++ b/api/admin/v1/email_log.go
@@ -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"`
+}
diff --git a/api/tenant/tenant.go b/api/tenant/tenant.go
index 99c49eb..9df7d76 100644
--- a/api/tenant/tenant.go
+++ b/api/tenant/tenant.go
@@ -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)
diff --git a/api/tenant/v1/email.go b/api/tenant/v1/email.go
index 87ef271..eafef1e 100644
--- a/api/tenant/v1/email.go
+++ b/api/tenant/v1/email.go
@@ -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{}
diff --git a/api/tenant/v1/member.go b/api/tenant/v1/member.go
index d9ec46f..e4c8b47 100644
--- a/api/tenant/v1/member.go
+++ b/api/tenant/v1/member.go
@@ -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:"显示名称"`
}
diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md
index c7cd18e..f6a6a71 100644
--- a/docs/reference/goframe-conventions.md
+++ b/docs/reference/goframe-conventions.md
@@ -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` 系列,占位符不会替换,多余实参还会被拼接到消息末尾。
+
diff --git a/internal/consts/consts.go b/internal/consts/consts.go
index 8d1a5ff..10f152e 100644
--- a/internal/consts/consts.go
+++ b/internal/consts/consts.go
@@ -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 已过期"
diff --git a/internal/controller/admin/admin_v1_email_log.go b/internal/controller/admin/admin_v1_email_log.go
new file mode 100644
index 0000000..a95aa5f
--- /dev/null
+++ b/internal/controller/admin/admin_v1_email_log.go
@@ -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)
+}
diff --git a/internal/controller/tenant/tenant_v1_email.go b/internal/controller/tenant/tenant_v1_email.go
index 53a5c21..e0b6812 100644
--- a/internal/controller/tenant/tenant_v1_email.go
+++ b/internal/controller/tenant/tenant_v1_email.go
@@ -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)
+}
diff --git a/internal/logic/admin/admin_user.go b/internal/logic/admin/admin_user.go
index 7771c21..e1fb0fc 100644
--- a/internal/logic/admin/admin_user.go
+++ b/internal/logic/admin/admin_user.go
@@ -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
}
diff --git a/internal/logic/admin/email_log.go b/internal/logic/admin/email_log.go
new file mode 100644
index 0000000..f567681
--- /dev/null
+++ b/internal/logic/admin/email_log.go
@@ -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
+}
diff --git a/internal/logic/admin/setup.go b/internal/logic/admin/setup.go
index 596b558..d7e29c1 100644
--- a/internal/logic/admin/setup.go
+++ b/internal/logic/admin/setup.go
@@ -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")
-}
diff --git a/internal/logic/common/captcha.go b/internal/logic/common/captcha.go
index 6084b5d..ee96079 100644
--- a/internal/logic/common/captcha.go
+++ b/internal/logic/common/captcha.go
@@ -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 {
diff --git a/internal/logic/common/errors.go b/internal/logic/common/errors.go
index 7845bb1..263df45 100644
--- a/internal/logic/common/errors.go
+++ b/internal/logic/common/errors.go
@@ -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")
+}
diff --git a/internal/logic/common/validation.go b/internal/logic/common/validation.go
index 5c207f9..fc9fc0d 100644
--- a/internal/logic/common/validation.go
+++ b/internal/logic/common/validation.go
@@ -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)
}
}
diff --git a/internal/logic/common/verify_code.go b/internal/logic/common/verify_code.go
index c346162..5ec0c55 100644
--- a/internal/logic/common/verify_code.go
+++ b/internal/logic/common/verify_code.go
@@ -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 {
diff --git a/internal/logic/tenant/email.go b/internal/logic/tenant/email.go
index f56eff2..01a42a1 100644
--- a/internal/logic/tenant/email.go
+++ b/internal/logic/tenant/email.go
@@ -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
+}
diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go
index 42f7b62..8d6e0f7 100644
--- a/internal/logic/tenant/member.go
+++ b/internal/logic/tenant/member.go
@@ -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()
diff --git a/internal/service/admin.go b/internal/service/admin.go
index 6189379..ef55be8 100644
--- a/internal/service/admin.go
+++ b/internal/service/admin.go
@@ -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.
diff --git a/internal/service/tenant.go b/internal/service/tenant.go
index d34926a..6f1f6c2 100644
--- a/internal/service/tenant.go
+++ b/internal/service/tenant.go
@@ -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 我的反馈列表
diff --git a/manifest/config/config.example.yaml b/manifest/config/config.example.yaml
index d7787bd..3aafd8c 100644
--- a/manifest/config/config.example.yaml
+++ b/manifest/config/config.example.yaml
@@ -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
diff --git a/manifest/docker/config.example.yaml b/manifest/docker/config.example.yaml
index 2982df3..3cb386e 100644
--- a/manifest/docker/config.example.yaml
+++ b/manifest/docker/config.example.yaml
@@ -73,14 +73,6 @@ jwt:
crypto:
encryptionKey: "请修改:运行 openssl rand -hex 32 生成 64 位十六进制密钥" # 请修改
-# 邮件 SMTP 配置(可选,用于邮箱验证码等功能)
-email:
- smtp:
- host: ""
- port: 587
- username: ""
- password: ""
- from: ""
# 演示模式(通过配置文件控制,不在管理后台设置)
# 开启后所有写操作被拦截,仅允许只读访问,并在控制台显示水印
diff --git a/migrations/000010_v0_1_12_version.sql b/migrations/000010_v0_1_12_version.sql
new file mode 100644
index 0000000..ce9bc0c
--- /dev/null
+++ b/migrations/000010_v0_1_12_version.sql
@@ -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;
diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts
index 3aeea58..3a074f6 100644
--- a/web/admin/src/router/admin.ts
+++ b/web/admin/src/router/admin.ts
@@ -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',
diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue
index de903ba..3767714 100644
--- a/web/admin/src/views/AdminLayout.vue
+++ b/web/admin/src/views/AdminLayout.vue
@@ -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 },
diff --git a/web/admin/src/views/operation/EmailLogsPage.vue b/web/admin/src/views/operation/EmailLogsPage.vue
new file mode 100644
index 0000000..040488b
--- /dev/null
+++ b/web/admin/src/views/operation/EmailLogsPage.vue
@@ -0,0 +1,319 @@
+
+
+
+ 邮件正文
+ {{ detailRecord.body }}
+
{{ errors.email }}
{{ errors.captcha }}
+{{ errors.newPassword }}
diff --git a/web/tenant/src/views/auth/JoinPage.vue b/web/tenant/src/views/auth/JoinPage.vue index 374cd07..82a5530 100644 --- a/web/tenant/src/views/auth/JoinPage.vue +++ b/web/tenant/src/views/auth/JoinPage.vue @@ -65,22 +65,6 @@ -{{ userErrors.password }}
充值金额为人民币(¥),支付成功后将按平台汇率换算为美元($)入账到您的钱包余额。
+