From 501726b8313b3f42e0a5e8cf48846630f77ad9ec Mon Sep 17 00:00:00 2001 From: Qian <39391600+qianfree@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:47:26 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- api/admin/admin.go | 3 + api/admin/v1/email_log.go | 60 ++++ api/tenant/tenant.go | 1 + api/tenant/v1/email.go | 16 +- api/tenant/v1/member.go | 2 +- docs/reference/goframe-conventions.md | 20 ++ internal/consts/consts.go | 2 + .../controller/admin/admin_v1_email_log.go | 18 + internal/controller/tenant/tenant_v1_email.go | 3 + internal/logic/admin/admin_user.go | 27 +- internal/logic/admin/email_log.go | 172 ++++++++++ internal/logic/admin/setup.go | 13 +- internal/logic/common/captcha.go | 20 ++ internal/logic/common/errors.go | 14 + internal/logic/common/validation.go | 2 +- internal/logic/common/verify_code.go | 5 + internal/logic/tenant/email.go | 91 ++++- internal/logic/tenant/member.go | 48 ++- internal/service/admin.go | 7 + internal/service/tenant.go | 5 + manifest/config/config.example.yaml | 8 - manifest/docker/config.example.yaml | 8 - migrations/000010_v0_1_12_version.sql | 25 ++ web/admin/src/router/admin.ts | 6 + web/admin/src/views/AdminLayout.vue | 1 + .../src/views/operation/EmailLogsPage.vue | 319 ++++++++++++++++++ .../src/views/system/settings/AuditTab.vue | 8 +- .../src/views/system/settings/EmailTab.vue | 5 +- .../src/views/system/settings/PaymentTab.vue | 4 +- web/admin/src/views/tenant/TenantsPage.vue | 4 +- .../components/common/MaintenanceBanner.vue | 2 +- .../common/PasswordStrengthMeter.vue | 52 +++ .../src/components/common/SlideCaptcha.vue | 10 + .../src/components/layout/AuthLayout.vue | 2 +- web/tenant/src/utils/request.ts | 15 +- web/tenant/src/views/Layout.vue | 4 +- .../src/views/auth/ForgotPasswordPage.vue | 74 ++-- web/tenant/src/views/auth/JoinPage.vue | 18 - web/tenant/src/views/auth/RegisterPage.vue | 2 + web/tenant/src/views/finance/WalletPage.vue | 26 ++ web/tenant/src/views/service/ModelsPage.vue | 16 +- web/tenant/src/views/settings/ProfilePage.vue | 154 ++++++++- 42 files changed, 1168 insertions(+), 124 deletions(-) create mode 100644 api/admin/v1/email_log.go create mode 100644 internal/controller/admin/admin_v1_email_log.go create mode 100644 internal/logic/admin/email_log.go create mode 100644 migrations/000010_v0_1_12_version.sql create mode 100644 web/admin/src/views/operation/EmailLogsPage.vue create mode 100644 web/tenant/src/components/common/PasswordStrengthMeter.vue 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 @@ diff --git a/web/admin/src/views/system/settings/EmailTab.vue b/web/admin/src/views/system/settings/EmailTab.vue index 02a1c0e..18461e7 100644 --- a/web/admin/src/views/system/settings/EmailTab.vue +++ b/web/admin/src/views/system/settings/EmailTab.vue @@ -14,7 +14,7 @@ const values = useFormValues() +
+ + + + + + + + + +
diff --git a/web/admin/src/views/system/settings/PaymentTab.vue b/web/admin/src/views/system/settings/PaymentTab.vue index 0f258e8..58c34d4 100644 --- a/web/admin/src/views/system/settings/PaymentTab.vue +++ b/web/admin/src/views/system/settings/PaymentTab.vue @@ -4,14 +4,14 @@ const values = useFormValues() function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') { if (direction === 'cny_to_usd') { - const usdToCny = parseFloat(values['payment_exchange_rate_usd_to_cny']) + const usdToCny = Number(values['payment_exchange_rate_usd_to_cny']) if (usdToCny > 0) { - values['payment_exchange_rate_cny_to_usd'] = String(Math.round((1 / usdToCny) * 10000) / 10000) + values['payment_exchange_rate_cny_to_usd'] = Math.round((1 / usdToCny) * 10000) / 10000 } } else { - const cnyToUsd = parseFloat(values['payment_exchange_rate_cny_to_usd']) + const cnyToUsd = Number(values['payment_exchange_rate_cny_to_usd']) if (cnyToUsd > 0) { - values['payment_exchange_rate_usd_to_cny'] = String(Math.round((1 / cnyToUsd) * 10000) / 10000) + values['payment_exchange_rate_usd_to_cny'] = Math.round((1 / cnyToUsd) * 10000) / 10000 } } } @@ -31,7 +31,7 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') {
@@ -49,8 +49,8 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') {
@@ -60,8 +60,8 @@ function syncRate(direction: 'cny_to_usd' | 'usd_to_cny') {
diff --git a/web/admin/src/views/system/settings/PerformanceTab.vue b/web/admin/src/views/system/settings/PerformanceTab.vue index f8d99c6..0b8083a 100644 --- a/web/admin/src/views/system/settings/PerformanceTab.vue +++ b/web/admin/src/views/system/settings/PerformanceTab.vue @@ -6,7 +6,7 @@ function num(key: string) { return Number(values[key]) || 0 } function set(key: string, v: number | undefined): void { - values[key] = String(v ?? 0) + values[key] = v ?? 0 } diff --git a/web/admin/src/views/tenant/MembersPage.vue b/web/admin/src/views/tenant/MembersPage.vue index 158ad3b..247e2d7 100644 --- a/web/admin/src/views/tenant/MembersPage.vue +++ b/web/admin/src/views/tenant/MembersPage.vue @@ -394,7 +394,7 @@ const { exporting, exportFile } = useExport({ - + diff --git a/web/admin/src/views/tenant/TenantsPage.vue b/web/admin/src/views/tenant/TenantsPage.vue index 822e733..84631b8 100644 --- a/web/admin/src/views/tenant/TenantsPage.vue +++ b/web/admin/src/views/tenant/TenantsPage.vue @@ -404,7 +404,7 @@ const { exporting, exportFile } = useExport({ - + diff --git a/web/tenant/src/components/common/ApiKeyEditModal.vue b/web/tenant/src/components/common/ApiKeyEditModal.vue index db42dd8..d4746b0 100644 --- a/web/tenant/src/components/common/ApiKeyEditModal.vue +++ b/web/tenant/src/components/common/ApiKeyEditModal.vue @@ -10,6 +10,8 @@ export interface ApiKeyData { name: string expires_at: string | null rate_limit_qps: number | null + rate_limit_concurrency: number | null + ip_whitelist: string[] | null total_quota: number | null used_quota: number | null } @@ -33,6 +35,8 @@ const form = reactive({ name: '', expires_at: '', rate_limit_qps: null as number | null, + rate_limit_concurrency: null as number | null, + ip_whitelist_text: '', total_quota: null as number | null, }) const loading = ref(false) @@ -133,12 +137,38 @@ function resetForm() { form.name = '' form.expires_at = '' form.rate_limit_qps = null + form.rate_limit_concurrency = null + form.ip_whitelist_text = '' form.total_quota = null selectedModelNames.value = [] modelSearch.value = '' createdKey.value = '' } +function parseIPWhitelist(): string[] { + return form.ip_whitelist_text + .split(/[\n,]/) + .map(item => item.trim()) + .filter(Boolean) +} + +function isNonNegativeNumber(value: number | null): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} + +function applyLimitFields(body: Record) { + if (isNonNegativeNumber(form.rate_limit_qps)) { + body.rate_limit_qps = form.rate_limit_qps + } + if (isNonNegativeNumber(form.rate_limit_concurrency)) { + body.rate_limit_concurrency = form.rate_limit_concurrency + } + if (isNonNegativeNumber(form.total_quota)) { + body.total_quota = form.total_quota + } + body.ip_whitelist = parseIPWhitelist() +} + const modalTitle = computed(() => { if (props.mode === 'create') { return createdKey.value ? '密钥创建成功' : (props.projectId ? '创建项目密钥' : '创建 API 密钥') @@ -164,6 +194,8 @@ watch(() => props.show, (val) => { if (props.mode === 'edit' && props.apiKey) { form.name = props.apiKey.name form.rate_limit_qps = props.apiKey.rate_limit_qps + form.rate_limit_concurrency = props.apiKey.rate_limit_concurrency + form.ip_whitelist_text = (props.apiKey.ip_whitelist || []).join('\n') form.total_quota = props.apiKey.total_quota loadModelScopes(props.apiKey.id) } @@ -184,6 +216,7 @@ async function handleSubmit() { scope: 'full', model_names: selectedModelNames.value, } + applyLimitFields(body) if (form.expires_at) body.expires_at = form.expires_at const url = props.projectId ? `/tenant/projects/${props.projectId}/api-keys` @@ -195,16 +228,7 @@ async function handleSubmit() { name: form.name, model_names: selectedModelNames.value, } - if (form.rate_limit_qps !== null && form.rate_limit_qps > 0) { - body.rate_limit_qps = form.rate_limit_qps - } else if (form.rate_limit_qps === 0) { - body.rate_limit_qps = 0 - } - if (form.total_quota !== null && form.total_quota > 0) { - body.total_quota = form.total_quota - } else if (form.total_quota === 0) { - body.total_quota = 0 - } + applyLimitFields(body) if (form.expires_at) body.expires_at = form.expires_at await request.put(`/tenant/api-keys/${props.apiKey!.id}`, body) toast.success('更新成功') @@ -288,10 +312,14 @@ const isFormMode = computed(() => {

-
+
- + +
+
+ +
@@ -300,25 +328,36 @@ const isFormMode = computed(() => {
-
- -
- -
- - - -
+
+ + +

支持 192.168.1.10 或 10.0.0.0/24,也可用逗号分隔。

+
+ +
+ +
+ +
+ + +
-

当前过期时间:{{ formatDate(apiKey.expires_at) }}

-

当前:永不过期

-

留空表示永不过期

+

当前过期时间:{{ formatDate(apiKey.expires_at) }}

+

当前:永不过期

+

留空表示永不过期

+
diff --git a/web/tenant/src/components/common/PasswordStrengthMeter.vue b/web/tenant/src/components/common/PasswordStrengthMeter.vue index 36684f7..5488c9f 100644 --- a/web/tenant/src/components/common/PasswordStrengthMeter.vue +++ b/web/tenant/src/components/common/PasswordStrengthMeter.vue @@ -22,7 +22,7 @@ const score = computed(() => { 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 (/[a-zA-Z]/.test(pw)) s++ if (/\d/.test(pw)) s++ if (/[^a-zA-Z0-9]/.test(pw)) s++ diff --git a/web/tenant/src/views/auth/JoinPage.vue b/web/tenant/src/views/auth/JoinPage.vue index 82a5530..0912ac4 100644 --- a/web/tenant/src/views/auth/JoinPage.vue +++ b/web/tenant/src/views/auth/JoinPage.vue @@ -75,7 +75,7 @@ v-model="form.password" type="password" required - placeholder="至少 8 位,含大小写字母和数字" + placeholder="至少 8 位,含字母和数字" class="input pl-11" />
diff --git a/web/tenant/src/views/service/ApiKeysPage.vue b/web/tenant/src/views/service/ApiKeysPage.vue index 6740c83..9796fbc 100644 --- a/web/tenant/src/views/service/ApiKeysPage.vue +++ b/web/tenant/src/views/service/ApiKeysPage.vue @@ -22,6 +22,7 @@ interface ApiKey { expires_at: string | null rate_limit_qps: number | null rate_limit_concurrency: number | null + ip_whitelist: string[] | null total_quota: number | null used_quota: number | null created_at: string @@ -104,12 +105,26 @@ function openEditModal(key: ApiKey) { name: key.name, expires_at: key.expires_at, rate_limit_qps: key.rate_limit_qps, + rate_limit_concurrency: key.rate_limit_concurrency, + ip_whitelist: key.ip_whitelist || [], total_quota: key.total_quota, used_quota: key.used_quota, } showEditModal.value = true } +function formatLimit(value: number | null | undefined, suffix = ''): string { + if (value === null || value === undefined) return '默认' + if (value <= 0) return '不限' + return `${value}${suffix}` +} + +function formatQuota(key: ApiKey): string { + const used = key.used_quota || 0 + if (!key.total_quota || key.total_quota <= 0) return `$${used.toFixed(2)} / 不限` + return `$${used.toFixed(2)} / $${key.total_quota.toFixed(2)}` +} + async function disableKey(keyId: number) { if (!await confirm({ message: '确定禁用该 API Key?禁用后将无法使用。', confirmText: '确认禁用', danger: true })) return try { @@ -171,6 +186,7 @@ onMounted(() => { 名称 Key 前缀 权限 + 限制 状态 过期时间 创建时间 @@ -191,6 +207,14 @@ onMounted(() => { 不限模型 + +
+
QPS:{{ formatLimit(key.rate_limit_qps) }}
+
并发:{{ formatLimit(key.rate_limit_concurrency) }}
+
额度:{{ formatQuota(key) }}
+
IP:{{ key.ip_whitelist?.length ? key.ip_whitelist.length + ' 条' : '不限' }}
+
+ {{ statusLabel[key.status] || key.status }} diff --git a/web/tenant/src/views/settings/ProfilePage.vue b/web/tenant/src/views/settings/ProfilePage.vue index e9053b7..df5bed8 100644 --- a/web/tenant/src/views/settings/ProfilePage.vue +++ b/web/tenant/src/views/settings/ProfilePage.vue @@ -561,7 +561,7 @@ async function handleOAuthUnlink(provider: string) { diff --git a/web/tenant/src/views/team/MemberDetailPage.vue b/web/tenant/src/views/team/MemberDetailPage.vue index 3a45b1f..cfe1142 100644 --- a/web/tenant/src/views/team/MemberDetailPage.vue +++ b/web/tenant/src/views/team/MemberDetailPage.vue @@ -673,7 +673,7 @@ onMounted(() => { diff --git a/web/tenant/src/views/team/MembersPage.vue b/web/tenant/src/views/team/MembersPage.vue index 335a157..6fb5e46 100644 --- a/web/tenant/src/views/team/MembersPage.vue +++ b/web/tenant/src/views/team/MembersPage.vue @@ -436,7 +436,7 @@ onMounted(() => {
diff --git a/web/tenant/src/views/team/ProjectDetailPage.vue b/web/tenant/src/views/team/ProjectDetailPage.vue index 25c806b..9c6c9e1 100644 --- a/web/tenant/src/views/team/ProjectDetailPage.vue +++ b/web/tenant/src/views/team/ProjectDetailPage.vue @@ -55,6 +55,7 @@ interface ApiKey { expires_at: string | null rate_limit_qps: number | null rate_limit_concurrency: number | null + ip_whitelist: string[] | null total_quota: number | null used_quota: number | null created_at: string @@ -210,12 +211,26 @@ function openEditModal(key: ApiKey) { name: key.name, expires_at: key.expires_at, rate_limit_qps: key.rate_limit_qps, + rate_limit_concurrency: key.rate_limit_concurrency, + ip_whitelist: key.ip_whitelist || [], total_quota: key.total_quota, used_quota: key.used_quota, } showEditModal.value = true } +function formatKeyLimit(value: number | null | undefined): string { + if (value === null || value === undefined) return '默认' + if (value <= 0) return '不限' + return String(value) +} + +function formatKeyQuota(key: ApiKey): string { + const used = key.used_quota || 0 + if (!key.total_quota || key.total_quota <= 0) return `$${used.toFixed(2)} / 不限` + return `$${used.toFixed(2)} / $${key.total_quota.toFixed(2)}` +} + async function deleteKey(keyId: number) { if (!await confirm({ message: '确定禁用该密钥?', confirmText: '确认禁用', danger: true })) return try { @@ -405,6 +420,7 @@ function switchTab(tab: 'overview' | 'keys' | 'usage') { 名称 Key 前缀 权限 + 限制 状态 过期时间 创建时间 @@ -423,6 +439,14 @@ function switchTab(tab: 'overview' | 'keys' | 'usage') { 不限模型 + +
+
QPS:{{ formatKeyLimit(key.rate_limit_qps) }}
+
并发:{{ formatKeyLimit(key.rate_limit_concurrency) }}
+
额度:{{ formatKeyQuota(key) }}
+
IP:{{ key.ip_whitelist?.length ? key.ip_whitelist.length + ' 条' : '不限' }}
+
+ {{ keyStatusLabel[key.status] || key.status }} From 79b922496899215875cac640414db8162766165e Mon Sep 17 00:00:00 2001 From: Qian <39391600+qianfree@users.noreply.github.com> Date: Tue, 23 Jun 2026 23:07:36 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=B3=A8=E5=86=8C=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix:修复权限问题 * fix:修复项目key类型错误问题 * fix:修复rbac权限问题 * fix:修复api密钥未及时失效的问题 * improve:优化渠道调度稳定性 * improve:优化路由 * fix:修复项目预算控制缺陷 * fix:加强限流等安全措施 * feat:添加限流相关字段的前端展示及接口字段 * improve:优化注册时密码要求,只需要字母和数字即可 * feat(setting):添加注册限流设置 * improve:优化前端数字类型转换 * chore:rbac权限放行 * feat:团队功能作为可选项,允许不设置团队的情况下进行注册 * feat:团队code只能设置一次 * improve:优化组织名称限制 * chore:合并数据库版本文件 --------- Co-authored-by: chengqian Co-authored-by: qian --- api/admin/v1/tenant.go | 2 +- api/tenant/v1/auth.go | 18 +- api/tenant/v1/organization.go | 4 +- docs/reference/goframe-conventions.md | 26 +++ internal/consts/consts.go | 8 + internal/dao/internal/aud_request_logs.go | 2 - internal/dao/internal/mdl_model_groups.go | 4 +- internal/dao/internal/opn_apps.go | 62 +++---- internal/dao/internal/plg_example_logs.go | 87 +++++++++ internal/dao/internal/sys_admin_users.go | 60 ++++--- .../dao/internal/sys_agreement_acceptances.go | 17 +- internal/dao/internal/sys_agreements.go | 23 ++- internal/dao/internal/tnt_tenants.go | 2 + internal/dao/plg_example_logs.go | 22 +++ internal/logic/admin/tenant.go | 13 +- internal/logic/common/validation.go | 51 ++++++ internal/logic/relay/provider.go | 1 - internal/logic/tenant/auth.go | 64 +++++-- internal/logic/tenant/code_generator.go | 55 ++++++ internal/logic/tenant/invitation.go | 3 + internal/logic/tenant/member.go | 23 +++ internal/logic/tenant/member_import.go | 3 + internal/logic/tenant/member_model_scope.go | 3 + internal/logic/tenant/member_quota.go | 3 + internal/logic/tenant/organization.go | 95 +++++++--- internal/logic/tenant/team_guard.go | 33 ++++ internal/model/do/aud_request_logs.go | 1 - internal/model/do/mdl_model_groups.go | 2 +- internal/model/do/opn_apps.go | 33 ++-- internal/model/do/plg_example_logs.go | 20 +++ .../model/do/sys_agreement_acceptances.go | 10 +- internal/model/do/sys_agreements.go | 16 +- internal/model/do/tnt_tenants.go | 1 + internal/model/entity/aud_request_logs.go | 1 - internal/model/entity/mdl_model_groups.go | 2 +- internal/model/entity/opn_apps.go | 31 ++-- internal/model/entity/plg_example_logs.go | 18 ++ .../model/entity/sys_agreement_acceptances.go | 14 +- internal/model/entity/sys_agreements.go | 26 +-- internal/model/entity/tnt_tenants.go | 1 + migrations/000011_v0_1_13_version.sql | 48 +++++ .../components/common/TeamLockedBanner.vue | 19 ++ web/tenant/src/stores/tenant-auth.ts | 21 ++- web/tenant/src/views/Layout.vue | 6 +- web/tenant/src/views/auth/RegisterPage.vue | 133 +------------- .../src/views/settings/OrganizationPage.vue | 165 +++++++++++++++++- web/tenant/src/views/team/InvitationsPage.vue | 7 +- .../src/views/team/MemberDetailPage.vue | 7 +- web/tenant/src/views/team/MembersPage.vue | 9 +- 49 files changed, 932 insertions(+), 343 deletions(-) create mode 100644 internal/dao/internal/plg_example_logs.go create mode 100644 internal/dao/plg_example_logs.go create mode 100644 internal/logic/tenant/code_generator.go create mode 100644 internal/logic/tenant/team_guard.go create mode 100644 internal/model/do/plg_example_logs.go create mode 100644 internal/model/entity/plg_example_logs.go create mode 100644 migrations/000011_v0_1_13_version.sql create mode 100644 web/tenant/src/components/common/TeamLockedBanner.vue diff --git a/api/admin/v1/tenant.go b/api/admin/v1/tenant.go index 0edb53a..62898cd 100644 --- a/api/admin/v1/tenant.go +++ b/api/admin/v1/tenant.go @@ -5,7 +5,7 @@ import "github.com/gogf/gf/v2/frame/g" // TenantCreateReq 创建租户请求 type TenantCreateReq struct { g.Meta `path:"/tenants" method:"post" mime:"json" tags:"管理后台-租户管理" summary:"创建租户"` - TenantName string `json:"tenant_name" v:"required|length:2,100#请输入租户名称|租户名称长度为2-100位" dc:"租户名称"` + TenantName string `json:"tenant_name" v:"required#请输入租户名称" dc:"租户名称(汉字最多8个,字母最多16个)"` TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入租户代码|租户代码为3-30位|租户代码仅允许小写字母、数字、中划线" dc:"租户代码"` Username string `json:"username" v:"required|length:3,50#请输入管理员用户名|用户名长度为3-50位" dc:"管理员用户名"` Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"管理员邮箱"` diff --git a/api/tenant/v1/auth.go b/api/tenant/v1/auth.go index 2b377a7..0e19822 100644 --- a/api/tenant/v1/auth.go +++ b/api/tenant/v1/auth.go @@ -8,8 +8,8 @@ type TenantRegisterReq struct { Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"` Code string `json:"code" dc:"邮箱验证码(邮箱验证开启时必填)"` Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"` - TenantName string `json:"tenant_name" v:"required|length:2,100#请输入组织名称|组织名称长度为2-100位" dc:"组织名称"` - TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入组织代码|组织代码为3-30位小写字母数字|组织代码格式不正确" dc:"组织代码"` + TenantName string `json:"tenant_name" dc:"组织名称(可选,留空自动生成;汉字最多8个,字母最多16个)"` + TenantCode string `json:"tenant_code" v:"length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#组织代码长度为3-30位|组织代码格式不正确" dc:"组织代码(可选,留空自动生成)"` Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"` CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(Turnstile关闭时必填)"` CaptchaX int `json:"captcha_x" dc:"滑块X坐标(Turnstile关闭时必填)"` @@ -21,9 +21,10 @@ type TenantRegisterRes struct { RefreshToken string `json:"refresh_token"` ExpiresAt string `json:"expires_at"` Tenant struct { - ID int64 `json:"id"` - Name string `json:"name"` - Code string `json:"code"` + ID int64 `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + TeamEnabled bool `json:"team_enabled"` } `json:"tenant"` User struct { ID int64 `json:"id"` @@ -51,9 +52,10 @@ type TenantLoginRes struct { TotpRequired bool `json:"totp_required"` // 是否需要 2FA 验证 ProvisionalToken string `json:"provisional_token,omitempty"` // 2FA 临时令牌 Tenant struct { - ID int64 `json:"id"` - Name string `json:"name"` - Code string `json:"code"` + ID int64 `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + TeamEnabled bool `json:"team_enabled"` } `json:"tenant"` User struct { ID int64 `json:"id"` diff --git a/api/tenant/v1/organization.go b/api/tenant/v1/organization.go index d69512d..96b35ad 100644 --- a/api/tenant/v1/organization.go +++ b/api/tenant/v1/organization.go @@ -11,6 +11,7 @@ type TenantOrgInfoRes struct { ID int64 `json:"id"` Name string `json:"name"` Code string `json:"code"` + TeamEnabled bool `json:"team_enabled"` LogoURL string `json:"logo_url"` Status string `json:"status"` Level int `json:"level"` @@ -23,7 +24,8 @@ type TenantOrgInfoRes struct { // TenantOrgUpdateReq 更新组织信息请求 type TenantOrgUpdateReq struct { g.Meta `path:"/organization" method:"put" mime:"json" tags:"租户控制台-组织" summary:"更新组织信息"` - Name *string `json:"name" v:"length:2,100#组织名称长度为2-100位" dc:"组织名称"` + Name *string `json:"name" dc:"组织名称(汉字最多8个,字母最多16个)"` + Code *string `json:"code" v:"length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#组织代码长度为3-30位|组织代码格式不正确" dc:"组织代码(首次设置即激活团队功能)"` LogoURL *string `json:"logo_url" dc:"Logo URL"` } diff --git a/docs/reference/goframe-conventions.md b/docs/reference/goframe-conventions.md index f6a6a71..6e247fd 100644 --- a/docs/reference/goframe-conventions.md +++ b/docs/reference/goframe-conventions.md @@ -505,3 +505,29 @@ return gerror.NewCodef(gcode.New(...), "%s包含禁用词「%s」,请修改后 **正确做法**:构造错误时区分 `NewXxx`(字面文本)与 `NewXxxf`(格式化)两个系列。凡消息含 `%s`/`%v`/`%d` 等占位符且需填入变量,必须用 `f` 变体(`gerror.Newf`、`gerror.NewCodef`、`gerror.NewWrapf`、`gerror.NewSkipf`);把格式串当字面文本传入 `NewXxx` 系列,占位符不会替换,多余实参还会被拼接到消息末尾。 +### 2026-06-23:Scan 查询单标量值(`*string`/`*int`)报参数类型错误 + +**问题描述**:`organization.go` 的 `GetOrgInfo` 查询租户等级名称时,`var levelName *string; dao.Xxx.Scan(&levelName)` 触发 WARN:`element of parameter "pointer" for function Scan should type of struct/*struct/[]struct/[]*struct`,等级名查不到(降级为空)。 + +**原因**:GoFrame 的 `Scan` **仅支持 struct / *struct / []struct / []*struct**,不支持标量类型(string/int/bool 等)的指针。`levelName` 声明为 `*string`,`&levelName` 是 `**string`,不在支持范围内,直接报参数类型错误。注意这和 2026-05-14 记录的「Scan 值类型 vs 指针类型」是两回事——那个讲的是 struct 查询无行时的行为差异,本次是 **Scan 根本不接受标量**。 + +**修复方式**:查询单个标量值改用 `Value()`,返回 `*gvar.Var`,再用 `.String()`/`.Int64()`/`.Bool()` 取值: +```go +// 错误 — Scan 不支持标量类型指针 +var levelName *string +dao.Xxx.Ctx(ctx).Where(...).Fields("name").Scan(&levelName) // 报参数类型错误 + +// 正确 — 单标量值用 Value() +v, err := dao.Xxx.Ctx(ctx).Where(...).Fields("name").Value() +if err != nil { + g.Log().Warningf(ctx, "查询失败: %v", err) +} else { + levelName = v.String() // 无记录时返回 "",不会报错 +} +``` + +**正确做法**:按查询意图选择 API: +- 查询单行记录 → `Scan(&structPtr)`(指针类型,无行返回 nil,见 2026-05-14 记录) +- 查询多行记录 → `Scan(&[]struct{})` +- 查询单个标量值(`COUNT`、`SUM`、单个字段)→ `Value()` 返回 `*gvar.Var`,用 `.String()/.Int64()/.Bool()` 取值,**不要用 `Scan(&scalar)` 或 `Scan(&scalarPtr)`** + diff --git a/internal/consts/consts.go b/internal/consts/consts.go index d3731f9..480fc00 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -179,6 +179,7 @@ const ( CodeGlobalRateLimitExceeded = 10091 // 全局限流 CodeForbiddenWord = 10096 // 名称包含禁用词 CodeEmailExists = 10097 // 邮箱已被使用 + CodeInvalidTenantName = 10098 // 组织名称长度不合法 // Setup errors CodeSetupCompleted = 10078 @@ -227,6 +228,9 @@ const ( CodeUpdateReplaceFailed = 10415 CodeUpdateRollbackFailed = 10416 CodeUpdateNotSupported = 10417 + + // Team feature gating + CodeTeamNotEnabled = 10418 ) // Common error messages @@ -257,6 +261,7 @@ const ( MsgUsernameExists = "用户名已存在" MsgEmailExists = "该邮箱已被使用" MsgTenantCodeExists = "租户代码已存在" + MsgTeamNotEnabled = "团队功能未启用,请先设置组织代码" MsgSessionExpired = "会话已过期" MsgTokenExpired = "Token 已过期" MsgTokenRevoked = "Token 已被撤销" @@ -346,6 +351,9 @@ const ( // Forbidden word MsgForbiddenWord = "名称包含禁用词,请修改后重试" + // Tenant name validation:汉字最多8个、字母最多16个(汉字按2计、其余按1计,总宽度≤16) + MsgInvalidTenantName = "组织名称长度超出限制(汉字最多8个,字母最多16个)" + // Agreement errors MsgAgreementNotFound = "协议版本不存在" MsgAgreementNotDraft = "仅草稿状态的协议可修改或删除" diff --git a/internal/dao/internal/aud_request_logs.go b/internal/dao/internal/aud_request_logs.go index 891cb4f..8cb94c2 100644 --- a/internal/dao/internal/aud_request_logs.go +++ b/internal/dao/internal/aud_request_logs.go @@ -52,7 +52,6 @@ type AudRequestLogsColumns struct { TaskUpstreamHeaders string // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录) TaskCompletedAt string // 异步任务达到终态的时间 AttachmentIds string // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3]) - Model string // 请求使用的模型名称(从请求体或 URL 中提取) } // audRequestLogsColumns holds the columns for the table aud_request_logs. @@ -88,7 +87,6 @@ var audRequestLogsColumns = AudRequestLogsColumns{ TaskUpstreamHeaders: "task_upstream_headers", TaskCompletedAt: "task_completed_at", AttachmentIds: "attachment_ids", - Model: "model", } // NewAudRequestLogsDao creates and returns a new DAO object for table data access. diff --git a/internal/dao/internal/mdl_model_groups.go b/internal/dao/internal/mdl_model_groups.go index 5b895d6..cef799d 100644 --- a/internal/dao/internal/mdl_model_groups.go +++ b/internal/dao/internal/mdl_model_groups.go @@ -26,9 +26,9 @@ type MdlModelGroupsColumns struct { Code string // 分组唯一标识(如 full_access、basic_chat) Description string // 分组描述 Status string // 状态:active(启用)/ disabled(禁用) - IsDefault string // 是否为新租户默认模型组,注册时自动关联 CreatedAt string // 创建时间 UpdatedAt string // 更新时间 + IsDefault string // 是否为新租户默认模型组,注册时自动关联 } // mdlModelGroupsColumns holds the columns for the table mdl_model_groups. @@ -38,9 +38,9 @@ var mdlModelGroupsColumns = MdlModelGroupsColumns{ Code: "code", Description: "description", Status: "status", - IsDefault: "is_default", CreatedAt: "created_at", UpdatedAt: "updated_at", + IsDefault: "is_default", } // NewMdlModelGroupsDao creates and returns a new DAO object for table data access. diff --git a/internal/dao/internal/opn_apps.go b/internal/dao/internal/opn_apps.go index 16d1398..bf9fbad 100644 --- a/internal/dao/internal/opn_apps.go +++ b/internal/dao/internal/opn_apps.go @@ -21,40 +21,42 @@ type OpnAppsDao struct { // OpnAppsColumns defines and stores column names for the table opn_apps. type OpnAppsColumns struct { - Id string // 主键ID - TenantId string // 所属租户ID - Name string // 应用名称 - Description string // 应用描述 - AppId string // 应用标识(opn_xxx 格式) - AppSecretHash string // App Secret 哈希(bcrypt) - Permissions string // 权限范围(JSON 数组) - IpWhitelist string // IP 白名单(JSON 数组,为空则不限制) - CallbackUrl string // OAuth 回调 URL - IsSandbox string // 是否沙箱应用 - Status string // 状态:active(启用)/ disabled(禁用) - RateLimit string // 每分钟请求上限 - LastUsedAt string // 最后使用时间 - CreatedAt string // 创建时间 - UpdatedAt string // 更新时间 + Id string // 主键ID + TenantId string // 所属租户ID + Name string // 应用名称 + Description string // 应用描述 + AppId string // 应用标识(opn_xxx 格式) + AppSecretHash string // App Secret 哈希(bcrypt) + Permissions string // 权限范围(JSON 数组) + IpWhitelist string // IP 白名单(JSON 数组,为空则不限制) + CallbackUrl string // OAuth 回调 URL + IsSandbox string // 是否沙箱应用 + Status string // 状态:active(启用)/ disabled(禁用) + RateLimit string // 每分钟请求上限 + LastUsedAt string // 最后使用时间 + CreatedAt string // 创建时间 + UpdatedAt string // 更新时间 + EncryptedSecret string // AES-256 encrypted App Secret for HMAC verification } // opnAppsColumns holds the columns for the table opn_apps. var opnAppsColumns = OpnAppsColumns{ - Id: "id", - TenantId: "tenant_id", - Name: "name", - Description: "description", - AppId: "app_id", - AppSecretHash: "app_secret_hash", - Permissions: "permissions", - IpWhitelist: "ip_whitelist", - CallbackUrl: "callback_url", - IsSandbox: "is_sandbox", - Status: "status", - RateLimit: "rate_limit", - LastUsedAt: "last_used_at", - CreatedAt: "created_at", - UpdatedAt: "updated_at", + Id: "id", + TenantId: "tenant_id", + Name: "name", + Description: "description", + AppId: "app_id", + AppSecretHash: "app_secret_hash", + Permissions: "permissions", + IpWhitelist: "ip_whitelist", + CallbackUrl: "callback_url", + IsSandbox: "is_sandbox", + Status: "status", + RateLimit: "rate_limit", + LastUsedAt: "last_used_at", + CreatedAt: "created_at", + UpdatedAt: "updated_at", + EncryptedSecret: "encrypted_secret", } // NewOpnAppsDao creates and returns a new DAO object for table data access. diff --git a/internal/dao/internal/plg_example_logs.go b/internal/dao/internal/plg_example_logs.go new file mode 100644 index 0000000..bb527de --- /dev/null +++ b/internal/dao/internal/plg_example_logs.go @@ -0,0 +1,87 @@ +// ========================================================================== +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ========================================================================== + +package internal + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// PlgExampleLogsDao is the data access object for the table plg_example_logs. +type PlgExampleLogsDao struct { + table string // table is the underlying table name of the DAO. + group string // group is the database configuration group name of the current DAO. + columns PlgExampleLogsColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// PlgExampleLogsColumns defines and stores column names for the table plg_example_logs. +type PlgExampleLogsColumns struct { + Id string // + TenantId string // + Action string // + Detail string // + CreatedAt string // +} + +// plgExampleLogsColumns holds the columns for the table plg_example_logs. +var plgExampleLogsColumns = PlgExampleLogsColumns{ + Id: "id", + TenantId: "tenant_id", + Action: "action", + Detail: "detail", + CreatedAt: "created_at", +} + +// NewPlgExampleLogsDao creates and returns a new DAO object for table data access. +func NewPlgExampleLogsDao(handlers ...gdb.ModelHandler) *PlgExampleLogsDao { + return &PlgExampleLogsDao{ + group: "default", + table: "plg_example_logs", + columns: plgExampleLogsColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *PlgExampleLogsDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *PlgExampleLogsDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *PlgExampleLogsDao) Columns() PlgExampleLogsColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *PlgExampleLogsDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. +func (dao *PlgExampleLogsDao) Ctx(ctx context.Context) *gdb.Model { + model := dao.DB().Model(dao.table) + for _, handler := range dao.handlers { + model = handler(model) + } + return model.Safe().Ctx(ctx) +} + +// Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. +func (dao *PlgExampleLogsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { + return dao.Ctx(ctx).Transaction(ctx, f) +} diff --git a/internal/dao/internal/sys_admin_users.go b/internal/dao/internal/sys_admin_users.go index a71c276..43d4f1b 100644 --- a/internal/dao/internal/sys_admin_users.go +++ b/internal/dao/internal/sys_admin_users.go @@ -21,38 +21,42 @@ type SysAdminUsersDao struct { // SysAdminUsersColumns defines and stores column names for the table sys_admin_users. type SysAdminUsersColumns struct { - Id string // 主键ID - Username string // 登录用户名 - PasswordHash string // 密码哈希(bcrypt) - Email string // 邮箱地址(可选,可为NULL) - DisplayName string // 显示名称 - Role string // 角色:super_admin(全权限)/ admin(可配置权限) - Status string // 状态:active(启用)/ disabled(禁用) - LastLoginAt string // 最后登录时间 - LastLoginIp string // 最后登录IP - CreatedAt string // 创建时间 - UpdatedAt string // 更新时间 - TotpSecret string // TOTP 密钥(AES-256 加密存储) - TotpEnabled string // 是否启用双因素认证 - BackupCodes string // 备用恢复码(bcrypt 哈希存储) + Id string // 主键ID + Username string // 登录用户名 + PasswordHash string // 密码哈希(bcrypt) + Email string // 邮箱地址(可选,可为NULL) + DisplayName string // 显示名称 + Role string // 角色:super_admin(全权限)/ admin(可配置权限) + Status string // 状态:active(启用)/ disabled(禁用) + LastLoginAt string // 最后登录时间 + LastLoginIp string // 最后登录IP + CreatedAt string // 创建时间 + UpdatedAt string // 更新时间 + TotpSecret string // TOTP 密钥(AES-256 加密存储) + TotpEnabled string // 是否启用双因素认证 + BackupCodes string // 备用恢复码(bcrypt 哈希存储) + FailedAttempts string // 连续登录失败次数(成功登录后归零) + LockedUntil string // 锁定截止时间(连续5次失败后锁定30分钟) } // sysAdminUsersColumns holds the columns for the table sys_admin_users. var sysAdminUsersColumns = SysAdminUsersColumns{ - Id: "id", - Username: "username", - PasswordHash: "password_hash", - Email: "email", - DisplayName: "display_name", - Role: "role", - Status: "status", - LastLoginAt: "last_login_at", - LastLoginIp: "last_login_ip", - CreatedAt: "created_at", - UpdatedAt: "updated_at", - TotpSecret: "totp_secret", - TotpEnabled: "totp_enabled", - BackupCodes: "backup_codes", + Id: "id", + Username: "username", + PasswordHash: "password_hash", + Email: "email", + DisplayName: "display_name", + Role: "role", + Status: "status", + LastLoginAt: "last_login_at", + LastLoginIp: "last_login_ip", + CreatedAt: "created_at", + UpdatedAt: "updated_at", + TotpSecret: "totp_secret", + TotpEnabled: "totp_enabled", + BackupCodes: "backup_codes", + FailedAttempts: "failed_attempts", + LockedUntil: "locked_until", } // NewSysAdminUsersDao creates and returns a new DAO object for table data access. diff --git a/internal/dao/internal/sys_agreement_acceptances.go b/internal/dao/internal/sys_agreement_acceptances.go index 3cede7e..e28e43a 100644 --- a/internal/dao/internal/sys_agreement_acceptances.go +++ b/internal/dao/internal/sys_agreement_acceptances.go @@ -21,13 +21,13 @@ type SysAgreementAcceptancesDao struct { // SysAgreementAcceptancesColumns defines and stores column names for the table sys_agreement_acceptances. type SysAgreementAcceptancesColumns struct { - Id string // + Id string // 主键ID AgreementId string // 关联协议版本ID - UserType string // 用户类型 + UserType string // 用户类型:admin(管理员) / tenant(租户用户) UserId string // 用户ID - IpAddress string // IP地址 - UserAgent string // User-Agent - CreatedAt string // + IpAddress string // 接受时的IP地址 + UserAgent string // 接受时的浏览器User-Agent + CreatedAt string // 接受时间 } // sysAgreementAcceptancesColumns holds the columns for the table sys_agreement_acceptances. @@ -71,7 +71,7 @@ func (dao *SysAgreementAcceptancesDao) Group() string { return dao.group } -// Ctx creates and returns a Model for the current DAO. +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. func (dao *SysAgreementAcceptancesDao) Ctx(ctx context.Context) *gdb.Model { model := dao.DB().Model(dao.table) for _, handler := range dao.handlers { @@ -81,6 +81,11 @@ func (dao *SysAgreementAcceptancesDao) Ctx(ctx context.Context) *gdb.Model { } // Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. func (dao *SysAgreementAcceptancesDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { return dao.Ctx(ctx).Transaction(ctx, f) } diff --git a/internal/dao/internal/sys_agreements.go b/internal/dao/internal/sys_agreements.go index c7559ec..e862273 100644 --- a/internal/dao/internal/sys_agreements.go +++ b/internal/dao/internal/sys_agreements.go @@ -21,19 +21,19 @@ type SysAgreementsDao struct { // SysAgreementsColumns defines and stores column names for the table sys_agreements. type SysAgreementsColumns struct { - Id string // - Code string // 协议标识码 - Version string // 版本号 + Id string // 主键ID + Code string // 协议标识码:terms(用户协议) / privacy(隐私政策) + Version string // 版本号,如 1.0、2.0 Title string // 协议标题 Content string // 协议正文(Markdown) Summary string // 版本变更摘要 - Status string // 状态 - IsCurrent string // 是否为当前生效版本 - ForceAccept string // 是否强制用户接受 + Status string // 状态:draft(草稿) / published(已发布) / archived(已归档) + IsCurrent string // 是否为该标识码的当前生效版本(每个code仅一条) + ForceAccept string // 是否强制用户接受(true=登录后必须接受才能继续) PublishedAt string // 发布时间 CreatedBy string // 创建的管理员ID - CreatedAt string // - UpdatedAt string // + CreatedAt string // 创建时间 + UpdatedAt string // 更新时间 } // sysAgreementsColumns holds the columns for the table sys_agreements. @@ -83,7 +83,7 @@ func (dao *SysAgreementsDao) Group() string { return dao.group } -// Ctx creates and returns a Model for the current DAO. +// Ctx creates and returns a Model for the current DAO. It automatically sets the context for the current operation. func (dao *SysAgreementsDao) Ctx(ctx context.Context) *gdb.Model { model := dao.DB().Model(dao.table) for _, handler := range dao.handlers { @@ -93,6 +93,11 @@ func (dao *SysAgreementsDao) Ctx(ctx context.Context) *gdb.Model { } // Transaction wraps the transaction logic using function f. +// It rolls back the transaction and returns the error if function f returns a non-nil error. +// It commits the transaction and returns nil if function f returns nil. +// +// Note: Do not commit or roll back the transaction in function f, +// as it is automatically handled by this function. func (dao *SysAgreementsDao) Transaction(ctx context.Context, f func(ctx context.Context, tx gdb.TX) error) (err error) { return dao.Ctx(ctx).Transaction(ctx, f) } diff --git a/internal/dao/internal/tnt_tenants.go b/internal/dao/internal/tnt_tenants.go index 3cd9544..0fba4f9 100644 --- a/internal/dao/internal/tnt_tenants.go +++ b/internal/dao/internal/tnt_tenants.go @@ -39,6 +39,7 @@ type TntTenantsColumns struct { MaxConcurrency string // 租户总并发上限(NULL表示跟随等级配置,0表示不限制) DefaultChannelScope string // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组) Level string // 当前等级(对应 tnt_tenant_level_configs.level) + TeamEnabled string // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度) } // tntTenantsColumns holds the columns for the table tnt_tenants. @@ -61,6 +62,7 @@ var tntTenantsColumns = TntTenantsColumns{ MaxConcurrency: "max_concurrency", DefaultChannelScope: "default_channel_scope", Level: "level", + TeamEnabled: "team_enabled", } // NewTntTenantsDao creates and returns a new DAO object for table data access. diff --git a/internal/dao/plg_example_logs.go b/internal/dao/plg_example_logs.go new file mode 100644 index 0000000..65fd27a --- /dev/null +++ b/internal/dao/plg_example_logs.go @@ -0,0 +1,22 @@ +// ================================================================================= +// This file is auto-generated by the GoFrame CLI tool. You may modify it as needed. +// ================================================================================= + +package dao + +import ( + "github.com/qianfree/team-api/internal/dao/internal" +) + +// plgExampleLogsDao is the data access object for the table plg_example_logs. +// You can define custom methods on it to extend its functionality as needed. +type plgExampleLogsDao struct { + *internal.PlgExampleLogsDao +} + +var ( + // PlgExampleLogs is a globally accessible object for table plg_example_logs operations. + PlgExampleLogs = plgExampleLogsDao{internal.NewPlgExampleLogsDao()} +) + +// Add your custom methods and functionality below. diff --git a/internal/logic/admin/tenant.go b/internal/logic/admin/tenant.go index 0acd8a6..b3d8de8 100644 --- a/internal/logic/admin/tenant.go +++ b/internal/logic/admin/tenant.go @@ -81,6 +81,11 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1 return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error()) } + tenantName := strings.TrimSpace(req.TenantName) + if err := common.ValidateTenantName(tenantName); err != nil { + return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error()) + } + count, err := dao.TntTenants.Ctx(ctx). Where("code", tenantCode).Count() if err != nil { @@ -122,7 +127,7 @@ func (s *sAdmin) CreateTenant(ctx context.Context, req *v1.TenantCreateReq) (*v1 err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{ - Name: strings.TrimSpace(req.TenantName), + Name: tenantName, Code: tenantCode, MaxMembers: maxMembersVal, MaxConcurrency: maxConcurrencyVal, @@ -470,7 +475,11 @@ func (s *sAdmin) UpdateTenant(ctx context.Context, req *v1.TenantUpdateReq) (*v1 data := do.TntTenants{} if req.Name != "" { - data.Name = req.Name + tenantName := strings.TrimSpace(req.Name) + if err := common.ValidateTenantName(tenantName); err != nil { + return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error()) + } + data.Name = tenantName } if req.MaxMembers != nil { if *req.MaxMembers < 1 { diff --git a/internal/logic/common/validation.go b/internal/logic/common/validation.go index 79000c8..2c13dfd 100644 --- a/internal/logic/common/validation.go +++ b/internal/logic/common/validation.go @@ -100,3 +100,54 @@ func ValidateForbiddenWords(ctx context.Context, value, fieldName string) error } return nil } + +// TenantNameMaxDisplayWidth 组织名称最大显示宽度。 +// 采用“显示宽度”模型:1 个汉字算 2、其余字符算 1,总宽度上限 16。 +// 等价于“汉字最多 8 个(8×2=16)”或“字母最多 16 个(16×1=16)”,中英文混排按比例折算。 +const TenantNameMaxDisplayWidth = 16 + +// displayWidth 计算“显示宽度”:CJK 汉字(unicode.Han)每个算 2,其余字符每个算 1。 +// 用于组织名称等中英文混排场景的长度衡量,使 8 个汉字与 16 个字母视觉等宽。 +func displayWidth(s string) int { + width := 0 + for _, r := range s { + if unicode.Is(unicode.Han, r) { + width += 2 + } else { + width += 1 + } + } + return width +} + +// ValidateTenantName 校验组织名称:显示宽度需在 2~TenantNameMaxDisplayWidth 之间。 +// 配合 API 层使用,规则为“汉字最多 8 个、字母最多 16 个”。 +func ValidateTenantName(name string) error { + w := displayWidth(name) + if w < 2 { + return gerror.New("组织名称长度不能少于 2 个字符") + } + if w > TenantNameMaxDisplayWidth { + return gerror.New(consts.MsgInvalidTenantName) + } + return nil +} + +// TruncateToDisplayWidth 按显示宽度截断字符串,使结果宽度不超过 max。 +// 用于自动生成的组织名称兜底(如“xxx 的组织”),避免用户名过长导致存入超长数据。 +func TruncateToDisplayWidth(s string, max int) string { + width := 0 + out := make([]rune, 0, len(s)) + for _, r := range s { + rw := 1 + if unicode.Is(unicode.Han, r) { + rw = 2 + } + if width+rw > max { + break + } + width += rw + out = append(out, r) + } + return string(out) +} diff --git a/internal/logic/relay/provider.go b/internal/logic/relay/provider.go index 4f54081..073572b 100644 --- a/internal/logic/relay/provider.go +++ b/internal/logic/relay/provider.go @@ -543,7 +543,6 @@ func (p *DataProviderImpl) RecordAudit(ctx context.Context, record *common.Audit StatusCode: record.StatusCode, ClientIp: record.ClientIP, UserAgent: record.UserAgent, - Model: record.Model, RequestBody: sysReq, ResponseBody: sysResp, TenantRequestBody: tntReq, diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index bb17ada..76ad473 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -36,16 +36,40 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. username := strings.TrimSpace(req.Username) tenantName := strings.TrimSpace(req.TenantName) - // 先校验禁用词:命中禁用词时优先返回更准确的提示, + // 简化注册:组织信息可选,留空时后端自动生成(个人模式,team_enabled=false) + userProvidedCode := tenantCode != "" + userProvidedName := tenantName != "" + + // 先校验用户名禁用词:命中禁用词时优先返回更准确的提示, // 避免被用户名格式校验的「不能包含特殊字符或中文」遮盖真实原因 if err := common.ValidateForbiddenWords(ctx, username, "用户名"); err != nil { return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error()) } - if err := common.ValidateForbiddenWords(ctx, tenantName, "组织名称"); err != nil { - return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error()) + + // 组织代码:留空则自动生成(org-);用户显式传入才校验禁用词 + if !userProvidedCode { + generated, err := generateUniqueTenantCode(ctx) + if err != nil { + return nil, err + } + tenantCode = generated + } else { + if err := common.ValidateForbiddenWords(ctx, tenantCode, "组织代码"); err != nil { + return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error()) + } } - if err := common.ValidateForbiddenWords(ctx, tenantCode, "组织代码"); err != nil { - return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error()) + + // 组织名称:留空则用用户名派生默认值;用户显式传入才校验禁用词 + if !userProvidedName { + // 用户名较长时“xxx 的组织”可能超出组织名称显示宽度上限,按宽度截断兜底 + tenantName = common.TruncateToDisplayWidth(fmt.Sprintf("%s 的组织", username), common.TenantNameMaxDisplayWidth) + } else { + if err := common.ValidateTenantName(tenantName); err != nil { + return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error()) + } + if err := common.ValidateForbiddenWords(ctx, tenantName, "组织名称"); err != nil { + return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error()) + } } // Validate username format @@ -84,14 +108,16 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. } } - // Check tenant code uniqueness - count, err := dao.TntTenants.Ctx(ctx). - Where("code", tenantCode).Count() - if err != nil { - return nil, err - } - if count > 0 { - return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists) + // Check tenant code uniqueness(仅用户显式传入时;自动生成的已在生成时查重) + if userProvidedCode { + count, err := dao.TntTenants.Ctx(ctx). + Where("code", tenantCode).Count() + if err != nil { + return nil, err + } + if count > 0 { + return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists) + } } // Validate password policy @@ -109,12 +135,13 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { // Create tenant tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{ - Name: strings.TrimSpace(req.TenantName), + Name: tenantName, Code: tenantCode, // MaxMembers/MaxConcurrency: nil = 跟随等级配置 // Level 1 defaults will apply - Level: 1, - Settings: "{}", + Level: 1, + Settings: "{}", + TeamEnabled: false, // 新注册默认个人模式,设置自定义 code 后才激活团队功能 }).Insert() if err != nil { return gerror.Wrapf(err, "create tenant") @@ -214,8 +241,9 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. ExpiresAt: tokenPair.ExpiresAt.Format(time.RFC3339), } res.Tenant.ID = tenantID - res.Tenant.Name = strings.TrimSpace(req.TenantName) + res.Tenant.Name = tenantName res.Tenant.Code = tenantCode + res.Tenant.TeamEnabled = false res.User.ID = ownerUserID res.User.Username = username res.User.Role = "owner" @@ -408,6 +436,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant res.Tenant.ID = tenant.Id res.Tenant.Name = tenant.Name res.Tenant.Code = tenant.Code + res.Tenant.TeamEnabled = tenant.TeamEnabled res.User.ID = user.Id res.User.Username = user.Username res.User.Role = user.Role @@ -455,6 +484,7 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant res.Tenant.ID = tenant.Id res.Tenant.Name = tenant.Name res.Tenant.Code = tenant.Code + res.Tenant.TeamEnabled = tenant.TeamEnabled res.User.ID = user.Id res.User.Username = user.Username res.User.Role = user.Role diff --git a/internal/logic/tenant/code_generator.go b/internal/logic/tenant/code_generator.go new file mode 100644 index 0000000..2804a54 --- /dev/null +++ b/internal/logic/tenant/code_generator.go @@ -0,0 +1,55 @@ +package tenant + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + + "github.com/gogf/gf/v2/frame/g" + + "github.com/qianfree/team-api/internal/dao" +) + +// generateUniqueTenantCode 生成一个不冲突的默认租户 code。 +// 格式:org-,满足正则 ^[a-z0-9][a-z0-9-]*[a-z0-9]$,长度 3-30。 +// 用 crypto/rand 保证不可预测,循环查重最多 5 次避免唯一索引冲突。 +func generateUniqueTenantCode(ctx context.Context) (string, error) { + const maxAttempts = 5 + for i := 0; i < maxAttempts; i++ { + code, err := randomTenantCode() + if err != nil { + return "", err + } + count, err := dao.TntTenants.Ctx(ctx).Where("code", code).Count() + if err != nil { + return "", err + } + if count == 0 { + return code, nil + } + g.Log().Warningf(ctx, "生成的租户 code 冲突,重试: %s", code) + } + return "", fmt.Errorf("生成唯一租户 code 失败(重试 %d 次)", maxAttempts) +} + +// randomTenantCode 生成单个 "org-" 形式的随机 code。 +func randomTenantCode() (string, error) { + b := make([]byte, 9) + if _, err := rand.Read(b); err != nil { + return "", err + } + const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz" + var chars []byte + tmp := new(big.Int).SetBytes(b) + base := big.NewInt(36) + mod := new(big.Int) + for tmp.Sign() > 0 && len(chars) < 16 { + tmp.DivMod(tmp, base, mod) + chars = append([]byte{alphabet[mod.Int64()]}, chars...) + } + if len(chars) == 0 { + chars = []byte{'0'} + } + return "org-" + string(chars), nil +} diff --git a/internal/logic/tenant/invitation.go b/internal/logic/tenant/invitation.go index e95848b..c164979 100644 --- a/internal/logic/tenant/invitation.go +++ b/internal/logic/tenant/invitation.go @@ -109,6 +109,9 @@ func (s *sTenant) RevokeInvitation(ctx context.Context, req *v1.TenantInvitation if role != "owner" && role != "admin" { return nil, common.NewForbiddenError("需要 owner 或 admin 权限") } + if err := requireTeamEnabled(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) diff --git a/internal/logic/tenant/member.go b/internal/logic/tenant/member.go index 8d6e0f7..d7207f3 100644 --- a/internal/logic/tenant/member.go +++ b/internal/logic/tenant/member.go @@ -90,6 +90,9 @@ func (s *sTenant) InviteMember(ctx context.Context, req *v1.TenantMemberInviteRe if role != "owner" && role != "admin" { return nil, common.NewForbiddenError("需要 owner 或 admin 权限") } + if err := requireTeamEnabled(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) creatorID := middleware.GetUserID(ctx) @@ -224,6 +227,11 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq) return nil, common.NewBusinessError(consts.CodeInvitationExpired, consts.MsgInvitationExpired) } + // 团队功能门控:目标租户必须已启用团队功能(公开接口,ctx 无 tenant_id,用目标租户 ID 校验) + if err := requireTeamEnabledForTenant(ctx, invitation.TenantID); err != nil { + return nil, err + } + email := strings.TrimSpace(strings.ToLower(req.Email)) // Validate password @@ -384,6 +392,9 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe if role != "owner" && role != "admin" { return nil, common.NewForbiddenError("需要 owner 或 admin 权限") } + if err := requireTeamEnabled(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) // Validate role @@ -501,6 +512,9 @@ func (s *sTenant) RemoveMember(ctx context.Context, req *v1.TenantMemberRemoveRe if role != "owner" && role != "admin" { return nil, common.NewForbiddenError("需要 owner 或 admin 权限") } + if err := requireTeamEnabled(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) currentUserID := middleware.GetUserID(ctx) memberID := req.Id @@ -670,6 +684,9 @@ func (s *sTenant) UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpda if role != "owner" && role != "admin" { return nil, common.NewForbiddenError("需要 owner 或 admin 权限") } + if err := requireTeamEnabled(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) currentUserID := middleware.GetUserID(ctx) memberID := req.Id @@ -719,6 +736,9 @@ func (s *sTenant) UpdateMemberRole(ctx context.Context, req *v1.TenantMemberUpda // ResetMemberPassword resets a member's password. Only admins can reset other members' passwords. func (s *sTenant) ResetMemberPassword(ctx context.Context, req *v1.TenantMemberResetPasswordReq) (*v1.TenantMemberResetPasswordRes, error) { + if err := requireTeamEnabled(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) currentUserID := middleware.GetUserID(ctx) memberID := req.Id @@ -932,6 +952,9 @@ func (s *sTenant) ExportMembers(ctx context.Context, req *v1.TenantMemberExportR if role != "owner" && role != "admin" { return nil, common.NewForbiddenError("需要 owner 或 admin 权限") } + if err := requireTeamEnabled(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) columns := []export.Column{ diff --git a/internal/logic/tenant/member_import.go b/internal/logic/tenant/member_import.go index 78b7f0a..a6def2f 100644 --- a/internal/logic/tenant/member_import.go +++ b/internal/logic/tenant/member_import.go @@ -35,6 +35,9 @@ type ImportResult struct { // MemberImport parses CSV content, validates, creates an import record. func (s *sTenant) MemberImport(ctx context.Context, req *v1.TenantMemberImportReq) (*v1.TenantMemberImportRes, error) { + if err := requireTeamEnabled(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) creatorID := middleware.GetUserID(ctx) diff --git a/internal/logic/tenant/member_model_scope.go b/internal/logic/tenant/member_model_scope.go index 807aeca..daef672 100644 --- a/internal/logic/tenant/member_model_scope.go +++ b/internal/logic/tenant/member_model_scope.go @@ -59,6 +59,9 @@ var memberModelScopeCache = lcommon.NewCache("member_model", 60*time.Second) // MemberModelScopesSet sets the available models for a member (full replace). func (s *sTenant) MemberModelScopesSet(ctx context.Context, req *v1.TenantMemberModelScopesSetReq) (*v1.TenantMemberModelScopesSetRes, error) { + if err := requireTeamEnabled(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) // 验证用户属于当前租户 diff --git a/internal/logic/tenant/member_quota.go b/internal/logic/tenant/member_quota.go index a8d5ddb..a1c11aa 100644 --- a/internal/logic/tenant/member_quota.go +++ b/internal/logic/tenant/member_quota.go @@ -59,6 +59,9 @@ func (s *sTenant) MemberQuotaSet(ctx context.Context, req *v1.TenantMemberQuotaS if role != "owner" && role != "admin" { return nil, common.NewForbiddenError("需要 owner 或 admin 权限") } + if err := requireTeamEnabled(ctx); err != nil { + return nil, err + } tenantID := middleware.GetTenantID(ctx) if req.QuotaType == "periodic" && req.Period == "" { diff --git a/internal/logic/tenant/organization.go b/internal/logic/tenant/organization.go index df57143..fa9e3b4 100644 --- a/internal/logic/tenant/organization.go +++ b/internal/logic/tenant/organization.go @@ -2,9 +2,11 @@ package tenant import ( "context" + "strings" "github.com/gogf/gf/v2/frame/g" + "github.com/qianfree/team-api/internal/consts" "github.com/qianfree/team-api/internal/dao" "github.com/qianfree/team-api/internal/logic/billing" "github.com/qianfree/team-api/internal/logic/common" @@ -31,14 +33,15 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1 tenantID := middleware.GetTenantID(ctx) var tenant *struct { - Id int64 `json:"id"` - Name string `json:"name"` - Code string `json:"code"` - LogoUrl string `json:"logo_url"` - Status string `json:"status"` - Level int `json:"level"` - MaxMembers *int `json:"max_members"` - CreatedAt string `json:"created_at"` + Id int64 `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + TeamEnabled bool `json:"team_enabled"` + LogoUrl string `json:"logo_url"` + Status string `json:"status"` + Level int `json:"level"` + MaxMembers *int `json:"max_members"` + CreatedAt string `json:"created_at"` } err := dao.TntTenants.Ctx(ctx). Where("id", tenantID).Scan(&tenant) @@ -57,15 +60,17 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1 return nil, err } - // Look up level name - var levelName *string + // Look up level name(单值查询用 Value(),Scan 仅支持 struct/*struct) + levelName := "" if tenant.Level > 0 { - err = dao.TntTenantLevelConfigs.Ctx(ctx). + v, err := dao.TntTenantLevelConfigs.Ctx(ctx). Where("level", tenant.Level). Fields("name"). - Scan(&levelName) + Value() if err != nil { g.Log().Warningf(ctx, "查询租户等级名称失败: %v", err) + } else { + levelName = v.String() } } @@ -73,18 +78,14 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1 effectiveMaxMembers, _, _ := billing.GetTenantEffectiveLimits(ctx, tenantID) return &v1.TenantOrgInfoRes{ - ID: tenant.Id, - Name: tenant.Name, - Code: tenant.Code, - LogoURL: tenant.LogoUrl, - Status: tenant.Status, - Level: tenant.Level, - LevelName: func() string { - if levelName != nil { - return *levelName - } - return "" - }(), + ID: tenant.Id, + Name: tenant.Name, + Code: tenant.Code, + TeamEnabled: tenant.TeamEnabled, + LogoURL: tenant.LogoUrl, + Status: tenant.Status, + Level: tenant.Level, + LevelName: levelName, MaxMembers: effectiveMaxMembers, MemberCount: int(memberCount), CreatedAt: tenant.CreatedAt, @@ -92,20 +93,64 @@ func (s *sTenant) GetOrgInfo(ctx context.Context, req *v1.TenantOrgInfoReq) (*v1 } // UpdateOrgInfo updates tenant organization info. +// 组织代码仅可在未启用团队功能时设置一次(设置即激活),启用后不可修改。 func (s *sTenant) UpdateOrgInfo(ctx context.Context, req *v1.TenantOrgUpdateReq) (*v1.TenantOrgUpdateRes, error) { if err := ownerOnly(ctx); err != nil { return nil, err } tenantID := middleware.GetTenantID(ctx) + // 规整 code(小写化);留空表示不修改 code + var newCode *string + if req.Code != nil { + c := strings.TrimSpace(strings.ToLower(*req.Code)) + if c == "" { + return nil, common.NewBadRequestError("组织代码不能为空") + } + newCode = &c + } + data := do.TntTenants{} if req.Name != nil { - data.Name = *req.Name + name := strings.TrimSpace(*req.Name) + if err := common.ValidateTenantName(name); err != nil { + return nil, common.NewBusinessError(consts.CodeInvalidTenantName, err.Error()) + } + data.Name = name } if req.LogoURL != nil { data.LogoUrl = *req.LogoURL } + // 处理组织代码设置:启用团队功能后不可修改(只能设置一次) + if newCode != nil { + // 已启用团队功能 → 拒绝修改 code + current, err := dao.TntTenants.Ctx(ctx). + Where("id", tenantID).Fields("team_enabled").Value() + if err != nil { + return nil, err + } + if current.Bool() { + return nil, common.NewBadRequestError("组织代码启用团队功能后不可修改") + } + // 首次设置:校验禁用词 + 唯一性(排除自己) + if err := common.ValidateForbiddenWords(ctx, *newCode, "组织代码"); err != nil { + return nil, common.NewBusinessError(consts.CodeForbiddenWord, err.Error()) + } + count, err := dao.TntTenants.Ctx(ctx). + Where("code", *newCode). + WhereNot("id", tenantID). + Count() + if err != nil { + return nil, err + } + if count > 0 { + return nil, common.NewBusinessError(consts.CodeTenantCodeExists, consts.MsgTenantCodeExists) + } + data.Code = *newCode + data.TeamEnabled = true + } + _, err := dao.TntTenants.Ctx(ctx).Where("id", tenantID).Data(data).Update() if err != nil { return nil, err diff --git a/internal/logic/tenant/team_guard.go b/internal/logic/tenant/team_guard.go new file mode 100644 index 0000000..511f1e6 --- /dev/null +++ b/internal/logic/tenant/team_guard.go @@ -0,0 +1,33 @@ +package tenant + +import ( + "context" + + "github.com/qianfree/team-api/internal/consts" + "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/logic/common" + "github.com/qianfree/team-api/internal/middleware" +) + +// requireTeamEnabled 校验当前认证用户所属租户已启用团队功能。 +// 用于 ctx 中携带 tenant_id 的团队写操作接口。 +func requireTeamEnabled(ctx context.Context) error { + return requireTeamEnabledForTenant(ctx, middleware.GetTenantID(ctx)) +} + +// requireTeamEnabledForTenant 校验指定租户已启用团队功能。 +// 用于 JoinByInvite 等公开接口:ctx 无 tenant_id,需在查到目标 tenant_id 后调用。 +func requireTeamEnabledForTenant(ctx context.Context, tenantID int64) error { + if tenantID <= 0 { + return common.NewForbiddenError("租户不存在") + } + enabled, err := dao.TntTenants.Ctx(ctx). + Where("id", tenantID).Fields("team_enabled").Value() + if err != nil { + return err + } + if !enabled.Bool() { + return common.NewBusinessError(consts.CodeTeamNotEnabled, consts.MsgTeamNotEnabled) + } + return nil +} diff --git a/internal/model/do/aud_request_logs.go b/internal/model/do/aud_request_logs.go index 60b7f18..6d6dbbd 100644 --- a/internal/model/do/aud_request_logs.go +++ b/internal/model/do/aud_request_logs.go @@ -43,5 +43,4 @@ type AudRequestLogs struct { TaskUpstreamHeaders any // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录) TaskCompletedAt *gtime.Time // 异步任务达到终态的时间 AttachmentIds any // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3]) - Model any // 请求使用的模型名称(从请求体或 URL 中提取) } diff --git a/internal/model/do/mdl_model_groups.go b/internal/model/do/mdl_model_groups.go index 43e2579..55c4b8e 100644 --- a/internal/model/do/mdl_model_groups.go +++ b/internal/model/do/mdl_model_groups.go @@ -17,7 +17,7 @@ type MdlModelGroups struct { Code any // 分组唯一标识(如 full_access、basic_chat) Description any // 分组描述 Status any // 状态:active(启用)/ disabled(禁用) - IsDefault any // 是否为新租户默认模型组,注册时自动关联 CreatedAt *gtime.Time // 创建时间 UpdatedAt *gtime.Time // 更新时间 + IsDefault any // 是否为新租户默认模型组,注册时自动关联 } diff --git a/internal/model/do/opn_apps.go b/internal/model/do/opn_apps.go index cb8caf8..0c4c008 100644 --- a/internal/model/do/opn_apps.go +++ b/internal/model/do/opn_apps.go @@ -11,20 +11,21 @@ import ( // OpnApps is the golang structure of table opn_apps for DAO operations like Where/Data. type OpnApps struct { - g.Meta `orm:"table:opn_apps, do:true"` - Id any // 主键ID - TenantId any // 所属租户ID - Name any // 应用名称 - Description any // 应用描述 - AppId any // 应用标识(opn_xxx 格式) - AppSecretHash any // App Secret 哈希(bcrypt) - Permissions any // 权限范围(JSON 数组) - IpWhitelist any // IP 白名单(JSON 数组,为空则不限制) - CallbackUrl any // OAuth 回调 URL - IsSandbox any // 是否沙箱应用 - Status any // 状态:active(启用)/ disabled(禁用) - RateLimit any // 每分钟请求上限 - LastUsedAt *gtime.Time // 最后使用时间 - CreatedAt *gtime.Time // 创建时间 - UpdatedAt *gtime.Time // 更新时间 + g.Meta `orm:"table:opn_apps, do:true"` + Id any // 主键ID + TenantId any // 所属租户ID + Name any // 应用名称 + Description any // 应用描述 + AppId any // 应用标识(opn_xxx 格式) + AppSecretHash any // App Secret 哈希(bcrypt) + Permissions any // 权限范围(JSON 数组) + IpWhitelist any // IP 白名单(JSON 数组,为空则不限制) + CallbackUrl any // OAuth 回调 URL + IsSandbox any // 是否沙箱应用 + Status any // 状态:active(启用)/ disabled(禁用) + RateLimit any // 每分钟请求上限 + LastUsedAt *gtime.Time // 最后使用时间 + CreatedAt *gtime.Time // 创建时间 + UpdatedAt *gtime.Time // 更新时间 + EncryptedSecret any // AES-256 encrypted App Secret for HMAC verification } diff --git a/internal/model/do/plg_example_logs.go b/internal/model/do/plg_example_logs.go new file mode 100644 index 0000000..e846fff --- /dev/null +++ b/internal/model/do/plg_example_logs.go @@ -0,0 +1,20 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package do + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// PlgExampleLogs is the golang structure of table plg_example_logs for DAO operations like Where/Data. +type PlgExampleLogs struct { + g.Meta `orm:"table:plg_example_logs, do:true"` + Id any // + TenantId any // + Action any // + Detail any // + CreatedAt *gtime.Time // +} diff --git a/internal/model/do/sys_agreement_acceptances.go b/internal/model/do/sys_agreement_acceptances.go index c95e300..1ff3233 100644 --- a/internal/model/do/sys_agreement_acceptances.go +++ b/internal/model/do/sys_agreement_acceptances.go @@ -12,11 +12,11 @@ import ( // SysAgreementAcceptances is the golang structure of table sys_agreement_acceptances for DAO operations like Where/Data. type SysAgreementAcceptances struct { g.Meta `orm:"table:sys_agreement_acceptances, do:true"` - Id any // + Id any // 主键ID AgreementId any // 关联协议版本ID - UserType any // 用户类型:admin / tenant + UserType any // 用户类型:admin(管理员) / tenant(租户用户) UserId any // 用户ID - IpAddress any // IP地址 - UserAgent any // User-Agent - CreatedAt *gtime.Time // + IpAddress any // 接受时的IP地址 + UserAgent any // 接受时的浏览器User-Agent + CreatedAt *gtime.Time // 接受时间 } diff --git a/internal/model/do/sys_agreements.go b/internal/model/do/sys_agreements.go index 2ff307f..8c43a1c 100644 --- a/internal/model/do/sys_agreements.go +++ b/internal/model/do/sys_agreements.go @@ -12,17 +12,17 @@ import ( // SysAgreements is the golang structure of table sys_agreements for DAO operations like Where/Data. type SysAgreements struct { g.Meta `orm:"table:sys_agreements, do:true"` - Id any // - Code any // 协议标识码 - Version any // 版本号 + Id any // 主键ID + Code any // 协议标识码:terms(用户协议) / privacy(隐私政策) + Version any // 版本号,如 1.0、2.0 Title any // 协议标题 Content any // 协议正文(Markdown) Summary any // 版本变更摘要 - Status any // 状态:draft / published / archived - IsCurrent any // 是否为当前生效版本 - ForceAccept any // 是否强制用户接受 + Status any // 状态:draft(草稿) / published(已发布) / archived(已归档) + IsCurrent any // 是否为该标识码的当前生效版本(每个code仅一条) + ForceAccept any // 是否强制用户接受(true=登录后必须接受才能继续) PublishedAt *gtime.Time // 发布时间 CreatedBy any // 创建的管理员ID - CreatedAt *gtime.Time // - UpdatedAt *gtime.Time // + CreatedAt *gtime.Time // 创建时间 + UpdatedAt *gtime.Time // 更新时间 } diff --git a/internal/model/do/tnt_tenants.go b/internal/model/do/tnt_tenants.go index d13ef3b..ec96349 100644 --- a/internal/model/do/tnt_tenants.go +++ b/internal/model/do/tnt_tenants.go @@ -30,4 +30,5 @@ type TntTenants struct { MaxConcurrency any // 租户总并发上限(NULL表示跟随等级配置,0表示不限制) DefaultChannelScope any // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组) Level any // 当前等级(对应 tnt_tenant_level_configs.level) + TeamEnabled any // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度) } diff --git a/internal/model/entity/aud_request_logs.go b/internal/model/entity/aud_request_logs.go index 6c30d51..bf20994 100644 --- a/internal/model/entity/aud_request_logs.go +++ b/internal/model/entity/aud_request_logs.go @@ -41,5 +41,4 @@ type AudRequestLogs struct { TaskUpstreamHeaders string `json:"task_upstream_headers" orm:"task_upstream_headers" description:"异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)"` // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录) TaskCompletedAt *gtime.Time `json:"task_completed_at" orm:"task_completed_at" description:"异步任务达到终态的时间"` // 异步任务达到终态的时间 AttachmentIds string `json:"attachment_ids" orm:"attachment_ids" description:"提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])"` // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3]) - Model string `json:"model" orm:"model" description:"请求使用的模型名称(从请求体或 URL 中提取)"` // 请求使用的模型名称(从请求体或 URL 中提取) } diff --git a/internal/model/entity/mdl_model_groups.go b/internal/model/entity/mdl_model_groups.go index 6ffa199..a9d2909 100644 --- a/internal/model/entity/mdl_model_groups.go +++ b/internal/model/entity/mdl_model_groups.go @@ -15,7 +15,7 @@ type MdlModelGroups struct { Code string `json:"code" orm:"code" description:"分组唯一标识(如 full_access、basic_chat)"` // 分组唯一标识(如 full_access、basic_chat) Description string `json:"description" orm:"description" description:"分组描述"` // 分组描述 Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用) - IsDefault bool `json:"is_default" orm:"is_default" description:"是否为新租户默认模型组,注册时自动关联"` // 是否为新租户默认模型组,注册时自动关联 CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 + IsDefault bool `json:"is_default" orm:"is_default" description:"是否为新租户默认模型组,注册时自动关联"` // 是否为新租户默认模型组,注册时自动关联 } diff --git a/internal/model/entity/opn_apps.go b/internal/model/entity/opn_apps.go index f99a09c..298c396 100644 --- a/internal/model/entity/opn_apps.go +++ b/internal/model/entity/opn_apps.go @@ -10,19 +10,20 @@ import ( // OpnApps is the golang structure for table opn_apps. type OpnApps struct { - Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID - TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID - Name string `json:"name" orm:"name" description:"应用名称"` // 应用名称 - Description string `json:"description" orm:"description" description:"应用描述"` // 应用描述 - AppId string `json:"app_id" orm:"app_id" description:"应用标识(opn_xxx 格式)"` // 应用标识(opn_xxx 格式) - AppSecretHash string `json:"app_secret_hash" orm:"app_secret_hash" description:"App Secret 哈希(bcrypt)"` // App Secret 哈希(bcrypt) - Permissions string `json:"permissions" orm:"permissions" description:"权限范围(JSON 数组)"` // 权限范围(JSON 数组) - IpWhitelist string `json:"ip_whitelist" orm:"ip_whitelist" description:"IP 白名单(JSON 数组,为空则不限制)"` // IP 白名单(JSON 数组,为空则不限制) - CallbackUrl string `json:"callback_url" orm:"callback_url" description:"OAuth 回调 URL"` // OAuth 回调 URL - IsSandbox bool `json:"is_sandbox" orm:"is_sandbox" description:"是否沙箱应用"` // 是否沙箱应用 - Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用) - RateLimit int `json:"rate_limit" orm:"rate_limit" description:"每分钟请求上限"` // 每分钟请求上限 - LastUsedAt *gtime.Time `json:"last_used_at" orm:"last_used_at" description:"最后使用时间"` // 最后使用时间 - CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 - UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 + Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID + TenantId int64 `json:"tenant_id" orm:"tenant_id" description:"所属租户ID"` // 所属租户ID + Name string `json:"name" orm:"name" description:"应用名称"` // 应用名称 + Description string `json:"description" orm:"description" description:"应用描述"` // 应用描述 + AppId string `json:"app_id" orm:"app_id" description:"应用标识(opn_xxx 格式)"` // 应用标识(opn_xxx 格式) + AppSecretHash string `json:"app_secret_hash" orm:"app_secret_hash" description:"App Secret 哈希(bcrypt)"` // App Secret 哈希(bcrypt) + Permissions string `json:"permissions" orm:"permissions" description:"权限范围(JSON 数组)"` // 权限范围(JSON 数组) + IpWhitelist string `json:"ip_whitelist" orm:"ip_whitelist" description:"IP 白名单(JSON 数组,为空则不限制)"` // IP 白名单(JSON 数组,为空则不限制) + CallbackUrl string `json:"callback_url" orm:"callback_url" description:"OAuth 回调 URL"` // OAuth 回调 URL + IsSandbox bool `json:"is_sandbox" orm:"is_sandbox" description:"是否沙箱应用"` // 是否沙箱应用 + Status string `json:"status" orm:"status" description:"状态:active(启用)/ disabled(禁用)"` // 状态:active(启用)/ disabled(禁用) + RateLimit int `json:"rate_limit" orm:"rate_limit" description:"每分钟请求上限"` // 每分钟请求上限 + LastUsedAt *gtime.Time `json:"last_used_at" orm:"last_used_at" description:"最后使用时间"` // 最后使用时间 + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 + UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 + EncryptedSecret string `json:"encrypted_secret" orm:"encrypted_secret" description:"AES-256 encrypted App Secret for HMAC verification"` // AES-256 encrypted App Secret for HMAC verification } diff --git a/internal/model/entity/plg_example_logs.go b/internal/model/entity/plg_example_logs.go new file mode 100644 index 0000000..937dc17 --- /dev/null +++ b/internal/model/entity/plg_example_logs.go @@ -0,0 +1,18 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// PlgExampleLogs is the golang structure for table plg_example_logs. +type PlgExampleLogs struct { + Id int64 `json:"id" orm:"id" description:""` // + TenantId int64 `json:"tenant_id" orm:"tenant_id" description:""` // + Action string `json:"action" orm:"action" description:""` // + Detail string `json:"detail" orm:"detail" description:""` // + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:""` // +} diff --git a/internal/model/entity/sys_agreement_acceptances.go b/internal/model/entity/sys_agreement_acceptances.go index 40babeb..d367a57 100644 --- a/internal/model/entity/sys_agreement_acceptances.go +++ b/internal/model/entity/sys_agreement_acceptances.go @@ -10,11 +10,11 @@ import ( // SysAgreementAcceptances is the golang structure for table sys_agreement_acceptances. type SysAgreementAcceptances struct { - Id int64 `json:"id" orm:"id"` - AgreementId int64 `json:"agreement_id" orm:"agreement_id"` - UserType string `json:"user_type" orm:"user_type"` - UserId int64 `json:"user_id" orm:"user_id"` - IpAddress string `json:"ip_address" orm:"ip_address"` - UserAgent string `json:"user_agent" orm:"user_agent"` - CreatedAt *gtime.Time `json:"created_at" orm:"created_at"` + Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID + AgreementId int64 `json:"agreement_id" orm:"agreement_id" description:"关联协议版本ID"` // 关联协议版本ID + UserType string `json:"user_type" orm:"user_type" description:"用户类型:admin(管理员) / tenant(租户用户)"` // 用户类型:admin(管理员) / tenant(租户用户) + UserId int64 `json:"user_id" orm:"user_id" description:"用户ID"` // 用户ID + IpAddress string `json:"ip_address" orm:"ip_address" description:"接受时的IP地址"` // 接受时的IP地址 + UserAgent string `json:"user_agent" orm:"user_agent" description:"接受时的浏览器User-Agent"` // 接受时的浏览器User-Agent + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"接受时间"` // 接受时间 } diff --git a/internal/model/entity/sys_agreements.go b/internal/model/entity/sys_agreements.go index 836147d..88bfc6e 100644 --- a/internal/model/entity/sys_agreements.go +++ b/internal/model/entity/sys_agreements.go @@ -10,17 +10,17 @@ import ( // SysAgreements is the golang structure for table sys_agreements. type SysAgreements struct { - Id int64 `json:"id" orm:"id"` - Code string `json:"code" orm:"code"` - Version string `json:"version" orm:"version"` - Title string `json:"title" orm:"title"` - Content string `json:"content" orm:"content"` - Summary string `json:"summary" orm:"summary"` - Status string `json:"status" orm:"status"` - IsCurrent bool `json:"is_current" orm:"is_current"` - ForceAccept bool `json:"force_accept" orm:"force_accept"` - PublishedAt *gtime.Time `json:"published_at" orm:"published_at"` - CreatedBy int64 `json:"created_by" orm:"created_by"` - CreatedAt *gtime.Time `json:"created_at" orm:"created_at"` - UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at"` + Id int64 `json:"id" orm:"id" description:"主键ID"` // 主键ID + Code string `json:"code" orm:"code" description:"协议标识码:terms(用户协议) / privacy(隐私政策)"` // 协议标识码:terms(用户协议) / privacy(隐私政策) + Version string `json:"version" orm:"version" description:"版本号,如 1.0、2.0"` // 版本号,如 1.0、2.0 + Title string `json:"title" orm:"title" description:"协议标题"` // 协议标题 + Content string `json:"content" orm:"content" description:"协议正文(Markdown)"` // 协议正文(Markdown) + Summary string `json:"summary" orm:"summary" description:"版本变更摘要"` // 版本变更摘要 + Status string `json:"status" orm:"status" description:"状态:draft(草稿) / published(已发布) / archived(已归档)"` // 状态:draft(草稿) / published(已发布) / archived(已归档) + IsCurrent bool `json:"is_current" orm:"is_current" description:"是否为该标识码的当前生效版本(每个code仅一条)"` // 是否为该标识码的当前生效版本(每个code仅一条) + ForceAccept bool `json:"force_accept" orm:"force_accept" description:"是否强制用户接受(true=登录后必须接受才能继续)"` // 是否强制用户接受(true=登录后必须接受才能继续) + PublishedAt *gtime.Time `json:"published_at" orm:"published_at" description:"发布时间"` // 发布时间 + CreatedBy int64 `json:"created_by" orm:"created_by" description:"创建的管理员ID"` // 创建的管理员ID + CreatedAt *gtime.Time `json:"created_at" orm:"created_at" description:"创建时间"` // 创建时间 + UpdatedAt *gtime.Time `json:"updated_at" orm:"updated_at" description:"更新时间"` // 更新时间 } diff --git a/internal/model/entity/tnt_tenants.go b/internal/model/entity/tnt_tenants.go index 12f4162..77adadc 100644 --- a/internal/model/entity/tnt_tenants.go +++ b/internal/model/entity/tnt_tenants.go @@ -28,4 +28,5 @@ type TntTenants struct { MaxConcurrency int `json:"max_concurrency" orm:"max_concurrency" description:"租户总并发上限(NULL表示跟随等级配置,0表示不限制)"` // 租户总并发上限(NULL表示跟随等级配置,0表示不限制) DefaultChannelScope string `json:"default_channel_scope" orm:"default_channel_scope" description:"默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组)"` // 默认渠道范围(NULL或[]表示全部可用,否则为channel_id数组) Level int `json:"level" orm:"level" description:"当前等级(对应 tnt_tenant_level_configs.level)"` // 当前等级(对应 tnt_tenant_level_configs.level) + TeamEnabled bool `json:"team_enabled" orm:"team_enabled" description:"团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)"` // 团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度) } diff --git a/migrations/000011_v0_1_13_version.sql b/migrations/000011_v0_1_13_version.sql new file mode 100644 index 0000000..92e7ea8 --- /dev/null +++ b/migrations/000011_v0_1_13_version.sql @@ -0,0 +1,48 @@ +-- +goose Up + +-- P1-1: 管理员暴力破解防护 — 新增失败计数和锁定字段 +ALTER TABLE sys_admin_users + ADD COLUMN IF NOT EXISTS failed_attempts INT DEFAULT 0 NOT NULL, + ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ; + +COMMENT ON COLUMN sys_admin_users.failed_attempts IS '连续登录失败次数(成功登录后归零)'; +COMMENT ON COLUMN sys_admin_users.locked_until IS '锁定截止时间(连续5次失败后锁定30分钟)'; + +-- P1-3: TOTP 密钥列宽度不足 — AES-GCM 加密 + base64 编码后约 80 字符,原 VARCHAR(64) 溢出 +ALTER TABLE sys_admin_users ALTER COLUMN totp_secret TYPE VARCHAR(255); +ALTER TABLE tnt_users ALTER COLUMN totp_secret TYPE VARCHAR(255); + +-- 开放平台应用密钥加密存储(HMAC 校验用) +ALTER TABLE opn_apps ADD COLUMN IF NOT EXISTS encrypted_secret TEXT; +COMMENT ON COLUMN opn_apps.encrypted_secret IS 'AES-256 encrypted App Secret for HMAC verification'; + +-- 修正项目 Key 类型:绑定项目的 Key 应为 project 类型而非 personal +UPDATE api_keys +SET key_type = 'project' +WHERE project_id IS NOT NULL + AND key_type = 'personal'; + +-- 团队功能开关:false=个人模式(默认),true=已激活团队 +ALTER TABLE tnt_tenants + ADD COLUMN IF NOT EXISTS team_enabled BOOLEAN NOT NULL DEFAULT FALSE; +COMMENT ON COLUMN tnt_tenants.team_enabled IS '团队功能是否启用:false=个人模式(默认),true=已激活团队(成员/RAM/邀请/额度)'; + +-- 存量租户均通过旧注册流程设置过自定义 code,视为团队已启用 +UPDATE tnt_tenants SET team_enabled = TRUE WHERE team_enabled = FALSE; + +-- +goose Down + +-- 团队功能开关(team_enabled 的存量数据回填不可逆,仅回滚结构) +ALTER TABLE tnt_tenants DROP COLUMN IF EXISTS team_enabled; + +-- 开放平台应用密钥(api_keys.key_type 的数据修正是不可逆的数据变更,仅回滚结构) +ALTER TABLE opn_apps DROP COLUMN IF EXISTS encrypted_secret; + +-- TOTP 密钥列宽度还原 +ALTER TABLE sys_admin_users ALTER COLUMN totp_secret TYPE VARCHAR(64); +ALTER TABLE tnt_users ALTER COLUMN totp_secret TYPE VARCHAR(64); + +-- 管理员暴力破解防护字段 +ALTER TABLE sys_admin_users + DROP COLUMN IF EXISTS failed_attempts, + DROP COLUMN IF EXISTS locked_until; diff --git a/web/tenant/src/components/common/TeamLockedBanner.vue b/web/tenant/src/components/common/TeamLockedBanner.vue new file mode 100644 index 0000000..f208976 --- /dev/null +++ b/web/tenant/src/components/common/TeamLockedBanner.vue @@ -0,0 +1,19 @@ + + + diff --git a/web/tenant/src/stores/tenant-auth.ts b/web/tenant/src/stores/tenant-auth.ts index a674e24..3fa90c0 100644 --- a/web/tenant/src/stores/tenant-auth.ts +++ b/web/tenant/src/stores/tenant-auth.ts @@ -7,6 +7,7 @@ export interface TenantInfo { id: number name: string code: string + team_enabled: boolean } export interface TenantUser { @@ -33,14 +34,15 @@ interface AuthResponse { } export interface RegisterPayload { - tenant_name: string - tenant_code: string username: string password: string email: string code?: string captcha_key?: string captcha_x?: number + // 组织信息可选(留空由后端自动生成),保留以兼容显式传入场景 + tenant_name?: string + tenant_code?: string } const STORE_KEY = 'tenant_auth' @@ -143,6 +145,20 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { return data.data } + // 拉取最新组织信息,更新 store.tenant(激活团队功能后调用,驱动菜单/页面响应) + async function refreshOrgInfo(): Promise { + const { data } = await request.get<{ data: { id: number; name: string; code: string; team_enabled: boolean } }>('/tenant/organization') + if (tenant.value && data.data) { + tenant.value = { + id: data.data.id, + name: data.data.name, + code: data.data.code, + team_enabled: data.data.team_enabled, + } + persist() + } + } + async function logout(): Promise { try { await request.post('/tenant/auth/logout') @@ -197,6 +213,7 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { login, applyTokensFrom2FA, register, + refreshOrgInfo, logout, refreshTokens, loadFromStorage, diff --git a/web/tenant/src/views/Layout.vue b/web/tenant/src/views/Layout.vue index 0c3a845..6dce4b4 100644 --- a/web/tenant/src/views/Layout.vue +++ b/web/tenant/src/views/Layout.vue @@ -251,9 +251,9 @@ onBeforeUnmount(() => { {{ tenantInfo.name.charAt(0) || 'T' }}
-
- {{ tenantInfo.name }} - {{ tenantInfo.code }} +
+ {{ tenantInfo.name }} + {{ tenantInfo.code }}
diff --git a/web/tenant/src/views/auth/RegisterPage.vue b/web/tenant/src/views/auth/RegisterPage.vue index b25d781..5650865 100644 --- a/web/tenant/src/views/auth/RegisterPage.vue +++ b/web/tenant/src/views/auth/RegisterPage.vue @@ -5,7 +5,6 @@ import { useTenantAuthStore } from '@/stores/tenant-auth' import { usePublicSettings } from '@/composables/usePublicSettings' import AuthLayout from '@/components/layout/AuthLayout.vue' 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' @@ -16,17 +15,14 @@ const router = useRouter() const authStore = useTenantAuthStore() const { settings, fetchSettings } = usePublicSettings() -const step = ref(1) const loading = ref(false) const codeSending = ref(false) const countdown = ref(0) const emailVerification = ref(true) const captcha = ref<{ captchaKey: string; captchaX: number }>({ captchaKey: '', captchaX: 0 }) const captchaRef = ref | null>(null) - const turnstileToken = ref('') let countdownTimer: ReturnType | null = null -const orgForm = reactive({ orgName: '', orgCode: '' }) const userForm = reactive({ email: '', code: '', @@ -36,11 +32,8 @@ const userForm = reactive({ agreed: false, }) -const orgErrors = reactive>({}) const userErrors = reactive>({}) -// Pending agreements - // Agreement view modal const showAgreementModal = ref(false) const agreementModalCode = ref('') @@ -54,29 +47,11 @@ function proceedAfterRegister() { router.push('/tenant/dashboard') } -const stepTitles = ['组织信息', '管理员信息'] - onMounted(async () => { await fetchSettings() emailVerification.value = settings.value.register_email_verification === true }) -function validateOrg(): boolean { - Object.keys(orgErrors).forEach((k) => delete orgErrors[k]) - - if (!orgForm.orgName.trim()) { - orgErrors.orgName = '请输入组织名称' - } - - if (!orgForm.orgCode.trim()) { - orgErrors.orgCode = '请输入组织代码' - } else if (!/^[a-z][a-z0-9-]{2,28}[a-z0-9]$/.test(orgForm.orgCode)) { - orgErrors.orgCode = '仅支持小写字母、数字和连字符(3-30 位)' - } - - return Object.keys(orgErrors).length === 0 -} - function validateUser(): boolean { Object.keys(userErrors).forEach((k) => delete userErrors[k]) @@ -138,15 +113,6 @@ function validateUsernameRealtime() { } } -function goNext() { - if (!validateOrg()) return - step.value = 2 -} - -function goBack() { - step.value = 1 -} - async function sendCode() { if (!userForm.email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(userForm.email)) { userErrors.email = '请先输入有效的邮箱地址' @@ -186,11 +152,10 @@ async function handleRegister() { loading.value = true try { + // 简化注册:仅提交账号信息,组织信息由后端自动生成(个人模式,团队功能后续开启) const payload = { email: userForm.email, password: userForm.password, - tenant_name: orgForm.orgName, - tenant_code: orgForm.orgCode, username: userForm.username, code: emailVerification.value ? userForm.code : undefined, captcha_key: emailVerification.value ? undefined : captcha.value.captchaKey, @@ -200,7 +165,7 @@ async function handleRegister() { await authStore.register(payload) proceedAfterRegister() } catch (err: any) { - captchaRef.value?.resetCaptcha() + captchaRef.value?.resetCaptcha() const apiErr = extractApiError(err) const msg = apiErr?.message || '注册失败' if (msg.includes('邮箱') || msg.includes('email')) { @@ -209,9 +174,6 @@ async function handleRegister() { userErrors.code = msg } else if (msg.includes('密码') || msg.includes('password')) { userErrors.password = msg - } else if (msg.includes('组织') || msg.includes('tenant')) { - orgErrors.orgName = msg - step.value = 1 } else if (msg.includes('用户名') || msg.includes('username')) { userErrors.username = msg } else { @@ -241,90 +203,11 @@ async function handleRegister() {
-

创建您的组织

-

几分钟即可开始使用 Team API

+

创建账号

+

几分钟即可开始使用,团队功能可后续开启

- -
-
-
- - {{ i + 1 }} -
- - {{ title }} - -
-
-
- - -
-
- - -

{{ orgErrors.orgName }}

-
- -
- - -

组织的唯一标识符,仅支持小写字母、数字和连字符

-

{{ orgErrors.orgCode }}

-
- - -
- - -
- -
-
-

{{ orgForm.orgName }}

-

{{ orgForm.orgCode }}

-
- -
- +
@@ -338,8 +221,8 @@ async function handleRegister() { placeholder="仅支持英文字母和数字,不能为纯数字" class="input pl-11" :class="{ 'input-error': userErrors.username }" - @input="validateUsernameRealtime" - /> + @input="validateUsernameRealtime" + />

{{ userErrors.username }}

@@ -441,7 +324,7 @@ async function handleRegister() {
diff --git a/web/tenant/src/views/settings/OrganizationPage.vue b/web/tenant/src/views/settings/OrganizationPage.vue index a9f3f94..3b92cb2 100644 --- a/web/tenant/src/views/settings/OrganizationPage.vue +++ b/web/tenant/src/views/settings/OrganizationPage.vue @@ -17,6 +17,15 @@ const nameForm = reactive({ }) const nameSaving = ref(false) +// 团队功能激活/管理 +const teamForm = reactive({ + name: '', + code: '', +}) +const teamSaving = ref(false) +const codeError = ref('') +const teamNameError = ref('') + const showTransferModal = ref(false) const transferForm = reactive({ new_owner_id: '', @@ -54,6 +63,8 @@ async function fetchOrgInfo() { const res: any = await request.get('/tenant/organization') orgInfo.value = res.data.data nameForm.name = res.data?.data?.name || '' + teamForm.name = res.data?.data?.name || '' + teamForm.code = res.data?.data?.code || '' } catch { // silent } finally { @@ -62,15 +73,21 @@ async function fetchOrgInfo() { } async function saveName() { - if (!nameForm.name.trim()) return + const name = nameForm.name.trim() + if (!name) return + if (calcOrgNameWidth(name) > ORG_NAME_MAX_WIDTH) { + toast.error('组织名称长度超出限制(汉字最多 8 个,字母最多 16 个)') + return + } nameSaving.value = true try { - await request.put('/tenant/organization', { name: nameForm.name }) + await request.put('/tenant/organization', { name }) if (orgInfo.value) { - orgInfo.value.name = nameForm.name - authStore.tenant!.name = nameForm.name + orgInfo.value.name = name } + // 同步 store 并持久化(refreshOrgInfo 内部 persist),否则刷新页面会回退到旧名称 + await authStore.refreshOrgInfo() editingName.value = false } catch { } finally { @@ -83,6 +100,55 @@ function cancelEditName() { editingName.value = false } +// 组织名称显示宽度:汉字算 2、其余算 1,上限 16(与后端一致:汉字最多 8 个,字母最多 16 个) +const ORG_NAME_MAX_WIDTH = 16 +function calcOrgNameWidth(val: string): number { + let w = 0 + for (const ch of val) { + w += /[一-鿿]/.test(ch) ? 2 : 1 + } + return w +} + +// 组织代码格式校验:3-30 位,小写字母/数字/连字符,字母数字开头结尾 +function validateTeamCode(val: string): boolean { + if (!val.trim()) { + codeError.value = '请输入组织代码' + return false + } + if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(val) || val.length < 3 || val.length > 30) { + codeError.value = '3-30 位,小写字母、数字、连字符,字母数字开头结尾' + return false + } + codeError.value = '' + return true +} + +// 启用团队功能:同时设置组织名称和组织代码(首次激活,后端置 team_enabled=true) +async function enableTeam() { + const name = teamForm.name.trim() + if (!name) { + toast.error('请输入组织名称') + return + } + if (calcOrgNameWidth(name) > ORG_NAME_MAX_WIDTH) { + teamNameError.value = '汉字最多 8 个,字母最多 16 个' + return + } + teamNameError.value = '' + if (!validateTeamCode(teamForm.code)) return + teamSaving.value = true + try { + await request.put('/tenant/organization', { name: teamForm.name, code: teamForm.code }) + toast.success('团队功能已启用') + await fetchOrgInfo() + await authStore.refreshOrgInfo() + } catch { + } finally { + teamSaving.value = false + } +} + async function handleTransferOwnership() { if (!transferForm.new_owner_id.trim() || !transferForm.password.trim()) return @@ -206,8 +272,88 @@ onMounted(() => {
+ +
+
+
+
+

团队功能

+

启用后可邀请成员、创建 RAM 账号、分配成员额度

+
+ + {{ orgInfo.team_enabled ? '已启用' : '未启用' }} + +
+
+
+ + + + + +
+
+ -
+

组织详情

@@ -252,10 +398,11 @@ onMounted(() => {

组织代码

- - {{ orgInfo.code }} - - 只读 + {{ orgInfo.code }} +
diff --git a/web/tenant/src/views/team/InvitationsPage.vue b/web/tenant/src/views/team/InvitationsPage.vue index a7a526c..2a40383 100644 --- a/web/tenant/src/views/team/InvitationsPage.vue +++ b/web/tenant/src/views/team/InvitationsPage.vue @@ -1,11 +1,15 @@