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 @@ + + + + + diff --git a/web/admin/src/views/system/settings/AuditTab.vue b/web/admin/src/views/system/settings/AuditTab.vue index 1428cc0..ace96a1 100644 --- a/web/admin/src/views/system/settings/AuditTab.vue +++ b/web/admin/src/views/system/settings/AuditTab.vue @@ -27,15 +27,15 @@ const values = useFormValues()
diff --git a/web/admin/src/views/system/settings/EmailTab.vue b/web/admin/src/views/system/settings/EmailTab.vue index 74989ea..02a1c0e 100644 --- a/web/admin/src/views/system/settings/EmailTab.vue +++ b/web/admin/src/views/system/settings/EmailTab.vue @@ -14,9 +14,10 @@ const values = useFormValues() diff --git a/web/admin/src/views/system/settings/PaymentTab.vue b/web/admin/src/views/system/settings/PaymentTab.vue index 2da45b9..0f258e8 100644 --- a/web/admin/src/views/system/settings/PaymentTab.vue +++ b/web/admin/src/views/system/settings/PaymentTab.vue @@ -31,8 +31,8 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') { diff --git a/web/admin/src/views/tenant/TenantsPage.vue b/web/admin/src/views/tenant/TenantsPage.vue index a5d1e88..822e733 100644 --- a/web/admin/src/views/tenant/TenantsPage.vue +++ b/web/admin/src/views/tenant/TenantsPage.vue @@ -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 }, diff --git a/web/tenant/src/components/common/MaintenanceBanner.vue b/web/tenant/src/components/common/MaintenanceBanner.vue index f92c621..e2deb9d 100644 --- a/web/tenant/src/components/common/MaintenanceBanner.vue +++ b/web/tenant/src/components/common/MaintenanceBanner.vue @@ -9,7 +9,7 @@ let timer: ReturnType | null = null onMounted(async () => { await fetchSettings() - timer = setInterval(() => fetchSettings(), 60_000) + timer = setInterval(() => fetchSettings(), 30 * 60 * 1000) }) onBeforeUnmount(() => { diff --git a/web/tenant/src/components/common/PasswordStrengthMeter.vue b/web/tenant/src/components/common/PasswordStrengthMeter.vue new file mode 100644 index 0000000..36684f7 --- /dev/null +++ b/web/tenant/src/components/common/PasswordStrengthMeter.vue @@ -0,0 +1,52 @@ + + + diff --git a/web/tenant/src/components/common/SlideCaptcha.vue b/web/tenant/src/components/common/SlideCaptcha.vue index f82d63d..d7b3517 100644 --- a/web/tenant/src/components/common/SlideCaptcha.vue +++ b/web/tenant/src/components/common/SlideCaptcha.vue @@ -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(null) const popupRef = ref(null) const imageRef = ref(null) diff --git a/web/tenant/src/components/layout/AuthLayout.vue b/web/tenant/src/components/layout/AuthLayout.vue index 50ba12e..123ab8a 100644 --- a/web/tenant/src/components/layout/AuthLayout.vue +++ b/web/tenant/src/components/layout/AuthLayout.vue @@ -77,7 +77,7 @@ async function fetchAnnouncements() { onMounted(() => { fetchAnnouncements() - timer = setInterval(fetchAnnouncements, 60_000) + timer = setInterval(fetchAnnouncements, 30 * 60 * 1000) }) onBeforeUnmount(() => { diff --git a/web/tenant/src/utils/request.ts b/web/tenant/src/utils/request.ts index a67cf17..1a313cd 100644 --- a/web/tenant/src/utils/request.ts +++ b/web/tenant/src/utils/request.ts @@ -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 { 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) } diff --git a/web/tenant/src/views/Layout.vue b/web/tenant/src/views/Layout.vue index b2e25d2..0c3a845 100644 --- a/web/tenant/src/views/Layout.vue +++ b/web/tenant/src/views/Layout.vue @@ -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() } diff --git a/web/tenant/src/views/auth/ForgotPasswordPage.vue b/web/tenant/src/views/auth/ForgotPasswordPage.vue index 9dd7741..d20131d 100644 --- a/web/tenant/src/views/auth/ForgotPasswordPage.vue +++ b/web/tenant/src/views/auth/ForgotPasswordPage.vue @@ -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>({}) -// Captcha state -const captcha = reactive({ captchaKey: '', captchaX: 0 }) +// Captcha state. Must be a ref: 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 | 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() {

{{ errors.email }}

- - + +
+ +

{{ errors.captcha }}

+
@@ -243,6 +266,7 @@ function goBack() { class="input" :class="{ 'input-error': errors.newPassword }" /> +

{{ 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 @@ -
- -
-
- -
- -
-
-
@@ -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, }), }) diff --git a/web/tenant/src/views/auth/RegisterPage.vue b/web/tenant/src/views/auth/RegisterPage.vue index 1cd5b93..b25d781 100644 --- a/web/tenant/src/views/auth/RegisterPage.vue +++ b/web/tenant/src/views/auth/RegisterPage.vue @@ -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 }" /> +

{{ userErrors.password }}

diff --git a/web/tenant/src/views/finance/WalletPage.vue b/web/tenant/src/views/finance/WalletPage.vue index 262f532..eb01ff1 100644 --- a/web/tenant/src/views/finance/WalletPage.vue +++ b/web/tenant/src/views/finance/WalletPage.vue @@ -479,6 +479,14 @@ onBeforeUnmount(() => { 充值 ¥{{ rechargeAmount || '—' }} + + +
+
+ +
+

充值金额为人民币(¥),支付成功后将按平台汇率换算为美元($)入账到您的钱包余额。

+
@@ -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 ========================================== */ diff --git a/web/tenant/src/views/service/ModelsPage.vue b/web/tenant/src/views/service/ModelsPage.vue index ed26c55..73217a6 100644 --- a/web/tenant/src/views/service/ModelsPage.vue +++ b/web/tenant/src/views/service/ModelsPage.vue @@ -1,8 +1,15 @@