diff --git a/api/admin/admin.go b/api/admin/admin.go index 9c3741a..38b6432 100644 --- a/api/admin/admin.go +++ b/api/admin/admin.go @@ -18,6 +18,15 @@ type IAdminV1 interface { AdminUserUpdateStatus(ctx context.Context, req *v1.AdminUserUpdateStatusReq) (res *v1.AdminUserUpdateStatusRes, err error) AdminUserResetPassword(ctx context.Context, req *v1.AdminUserResetPasswordReq) (res *v1.AdminUserResetPasswordRes, err error) AdminUserExport(ctx context.Context, req *v1.AdminUserExportReq) (res *v1.AdminUserExportRes, err error) + AgreementCreate(ctx context.Context, req *v1.AgreementCreateReq) (res *v1.AgreementCreateRes, err error) + AgreementList(ctx context.Context, req *v1.AgreementListReq) (res *v1.AgreementListRes, err error) + AgreementGet(ctx context.Context, req *v1.AgreementGetReq) (res *v1.AgreementGetRes, err error) + AgreementUpdate(ctx context.Context, req *v1.AgreementUpdateReq) (res *v1.AgreementUpdateRes, err error) + AgreementDelete(ctx context.Context, req *v1.AgreementDeleteReq) (res *v1.AgreementDeleteRes, err error) + AgreementPublish(ctx context.Context, req *v1.AgreementPublishReq) (res *v1.AgreementPublishRes, err error) + AgreementAcceptanceList(ctx context.Context, req *v1.AgreementAcceptanceListReq) (res *v1.AgreementAcceptanceListRes, err error) + AdminAgreementPending(ctx context.Context, req *v1.AdminAgreementPendingReq) (res *v1.AdminAgreementPendingRes, err error) + AdminAgreementAccept(ctx context.Context, req *v1.AdminAgreementAcceptReq) (res *v1.AdminAgreementAcceptRes, err error) AuditConfigGet(ctx context.Context, req *v1.AuditConfigGetReq) (res *v1.AuditConfigGetRes, err error) AuditConfigUpdate(ctx context.Context, req *v1.AuditConfigUpdateReq) (res *v1.AuditConfigUpdateRes, err error) OperationLogList(ctx context.Context, req *v1.OperationLogListReq) (res *v1.OperationLogListRes, err error) diff --git a/api/admin/v1/agreement.go b/api/admin/v1/agreement.go new file mode 100644 index 0000000..9746107 --- /dev/null +++ b/api/admin/v1/agreement.go @@ -0,0 +1,146 @@ +package v1 + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// === 用户协议管理(管理后台 CRUD) === + +type AgreementCreateReq struct { + g.Meta `path:"/agreements" method:"post" mime:"json" tags:"管理后台-用户协议" summary:"创建协议版本"` + Code string `json:"code" v:"required|length:1,50" dc:"协议标识码"` + Version string `json:"version" v:"required|length:1,50" dc:"版本号"` + Title string `json:"title" v:"required|length:1,200" dc:"协议标题"` + Content string `json:"content" v:"required" dc:"协议正文(Markdown)"` + Summary string `json:"summary" v:"max-length:500" dc:"版本变更摘要"` + ForceAccept *bool `json:"force_accept" d:"true" dc:"是否强制用户接受"` +} + +type AgreementCreateRes struct { + Id int64 `json:"id"` +} + +type AgreementListReq struct { + g.Meta `path:"/agreements" method:"get" mime:"json" tags:"管理后台-用户协议" summary:"协议版本列表"` + Page int `json:"page" in:"query" d:"1"` + PageSize int `json:"page_size" in:"query" d:"20"` + Code string `json:"code" in:"query" dc:"协议标识码筛选"` + Status string `json:"status" in:"query" dc:"状态筛选"` +} + +type AgreementItem struct { + Id int64 `json:"id"` + Code string `json:"code"` + Version string `json:"version"` + Title string `json:"title"` + Summary string `json:"summary"` + Status string `json:"status"` + IsCurrent bool `json:"is_current"` + ForceAccept bool `json:"force_accept"` + PublishedAt *gtime.Time `json:"published_at,omitempty"` + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} + +type AgreementListRes struct { + List []*AgreementItem `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +type AgreementGetReq struct { + g.Meta `path:"/agreements/{id}" method:"get" mime:"json" tags:"管理后台-用户协议" summary:"协议版本详情"` + Id int64 `json:"id" in:"path" v:"required|min:1"` +} + +type AgreementGetRes struct { + Id int64 `json:"id"` + Code string `json:"code"` + Version string `json:"version"` + Title string `json:"title"` + Content string `json:"content"` + Summary string `json:"summary"` + Status string `json:"status"` + IsCurrent bool `json:"is_current"` + ForceAccept bool `json:"force_accept"` + PublishedAt *gtime.Time `json:"published_at,omitempty"` + CreatedBy int64 `json:"created_by"` + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} + +type AgreementUpdateReq struct { + g.Meta `path:"/agreements/{id}" method:"put" mime:"json" tags:"管理后台-用户协议" summary:"更新协议版本"` + Id int64 `json:"id" in:"path" v:"required|min:1"` + Title string `json:"title" v:"required|length:1,200" dc:"协议标题"` + Content string `json:"content" v:"required" dc:"协议正文(Markdown)"` + Summary string `json:"summary" v:"max-length:500" dc:"版本变更摘要"` + ForceAccept *bool `json:"force_accept" dc:"是否强制用户接受"` +} + +type AgreementUpdateRes struct{} + +type AgreementDeleteReq struct { + g.Meta `path:"/agreements/{id}" method:"delete" mime:"json" tags:"管理后台-用户协议" summary:"删除协议版本"` + Id int64 `json:"id" in:"path" v:"required|min:1"` +} + +type AgreementDeleteRes struct{} + +type AgreementPublishReq struct { + g.Meta `path:"/agreements/{id}/publish" method:"post" mime:"json" tags:"管理后台-用户协议" summary:"发布协议版本"` + Id int64 `json:"id" in:"path" v:"required|min:1"` +} + +type AgreementPublishRes struct{} + +// === 协议接受记录(管理后台审计) === + +type AgreementAcceptanceListReq struct { + g.Meta `path:"/agreements/{id}/acceptances" method:"get" mime:"json" tags:"管理后台-用户协议" summary:"协议接受记录"` + Id int64 `json:"id" in:"path" v:"required|min:1"` + Page int `json:"page" in:"query" d:"1"` + PageSize int `json:"page_size" in:"query" d:"20"` +} + +type AgreementAcceptanceItem struct { + Id int64 `json:"id"` + UserType string `json:"user_type"` + UserId int64 `json:"user_id"` + IpAddress string `json:"ip_address"` + CreatedAt *gtime.Time `json:"created_at"` +} + +type AgreementAcceptanceListRes struct { + List []*AgreementAcceptanceItem `json:"list"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// === 管理员待接受协议 === + +type AdminAgreementPendingReq struct { + g.Meta `path:"/agreements/pending" method:"get" mime:"json" tags:"管理后台-用户协议" summary:"当前管理员待接受的协议"` +} + +type PendingAgreementItem struct { + Id int64 `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Version string `json:"version"` + Content string `json:"content"` +} + +type AdminAgreementPendingRes struct { + List []*PendingAgreementItem `json:"list"` +} + +type AdminAgreementAcceptReq struct { + g.Meta `path:"/agreements/accept" method:"post" mime:"json" tags:"管理后台-用户协议" summary:"管理员接受协议"` + AgreementIds []int64 `json:"agreement_ids" v:"required|min-length:1" dc:"协议版本ID列表"` +} + +type AdminAgreementAcceptRes struct{} diff --git a/api/admin/v1/auth.go b/api/admin/v1/auth.go index 26dc4c0..6b8e8d6 100644 --- a/api/admin/v1/auth.go +++ b/api/admin/v1/auth.go @@ -23,6 +23,15 @@ type AdminLoginRes struct { DisplayName string `json:"display_name"` Role string `json:"role"` } `json:"user"` + PendingAgreements []*LoginPendingAgreement `json:"pending_agreements,omitempty"` // 待接受协议列表 +} + +// LoginPendingAgreement 登录时返回的待接受协议信息 +type LoginPendingAgreement struct { + Id int64 `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Version string `json:"version"` } // AdminLogoutReq 管理后台登出请求 diff --git a/api/tenant/tenant.go b/api/tenant/tenant.go index 7aab302..99c49eb 100644 --- a/api/tenant/tenant.go +++ b/api/tenant/tenant.go @@ -11,6 +11,10 @@ import ( ) type ITenantV1 interface { + TenantAgreementPending(ctx context.Context, req *v1.TenantAgreementPendingReq) (res *v1.TenantAgreementPendingRes, err error) + TenantAgreementAccept(ctx context.Context, req *v1.TenantAgreementAcceptReq) (res *v1.TenantAgreementAcceptRes, err error) + AgreementCurrentList(ctx context.Context, req *v1.AgreementCurrentListReq) (res *v1.AgreementCurrentListRes, err error) + AgreementCurrentGet(ctx context.Context, req *v1.AgreementCurrentGetReq) (res *v1.AgreementCurrentGetRes, err error) TenantApiKeyList(ctx context.Context, req *v1.TenantApiKeyListReq) (res *v1.TenantApiKeyListRes, err error) TenantApiKeyCreate(ctx context.Context, req *v1.TenantApiKeyCreateReq) (res *v1.TenantApiKeyCreateRes, err error) TenantApiKeyDelete(ctx context.Context, req *v1.TenantApiKeyDeleteReq) (res *v1.TenantApiKeyDeleteRes, err error) diff --git a/api/tenant/v1/agreement.go b/api/tenant/v1/agreement.go new file mode 100644 index 0000000..3bf893d --- /dev/null +++ b/api/tenant/v1/agreement.go @@ -0,0 +1,67 @@ +package v1 + +import ( + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" +) + +// === 用户协议(租户控制台) === + +// --- 租户用户待接受协议 --- + +type TenantAgreementPendingReq struct { + g.Meta `path:"/agreements/pending" method:"get" mime:"json" tags:"租户控制台-用户协议" summary:"当前用户待接受的协议"` +} + +type TenantPendingAgreementItem struct { + Id int64 `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Version string `json:"version"` + Content string `json:"content"` +} + +type TenantAgreementPendingRes struct { + List []*TenantPendingAgreementItem `json:"list"` +} + +type TenantAgreementAcceptReq struct { + g.Meta `path:"/agreements/accept" method:"post" mime:"json" tags:"租户控制台-用户协议" summary:"用户接受协议"` + AgreementIds []int64 `json:"agreement_ids" v:"required|min-length:1" dc:"协议版本ID列表"` +} + +type TenantAgreementAcceptRes struct{} + +// --- 公开端点(登录/注册页面使用,无需认证) --- + +type AgreementCurrentListReq struct { + g.Meta `path:"/agreements/current" method:"get" mime:"json" tags:"公开-用户协议" summary:"所有当前生效的协议列表" group:"public" middleware:"-"` +} + +type PublicAgreementItem struct { + Id int64 `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Version string `json:"version"` + ForceAccept bool `json:"force_accept"` + PublishedAt *gtime.Time `json:"published_at,omitempty"` +} + +type AgreementCurrentListRes struct { + List []*PublicAgreementItem `json:"list"` +} + +type AgreementCurrentGetReq struct { + g.Meta `path:"/agreements/current/{code}" method:"get" mime:"json" tags:"公开-用户协议" summary:"按标识码获取当前协议详情" group:"public" middleware:"-"` + Code string `json:"code" in:"path" v:"required|length:1,50"` +} + +type AgreementCurrentGetRes struct { + Id int64 `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Version string `json:"version"` + Content string `json:"content"` + ForceAccept bool `json:"force_accept"` + PublishedAt *gtime.Time `json:"published_at,omitempty"` +} diff --git a/api/tenant/v1/auth.go b/api/tenant/v1/auth.go index 122ad3a..2b377a7 100644 --- a/api/tenant/v1/auth.go +++ b/api/tenant/v1/auth.go @@ -30,6 +30,7 @@ type TenantRegisterRes struct { Username string `json:"username"` Role string `json:"role"` } `json:"user"` + PendingAgreements []*TenantLoginPendingAgreement `json:"pending_agreements,omitempty"` // 待接受协议列表 } // TenantLoginReq 租户登录请求 @@ -59,7 +60,16 @@ type TenantLoginRes struct { Username string `json:"username"` Role string `json:"role"` } `json:"user"` - MaintenanceInfo *LoginMaintenanceInfo `json:"maintenance_info,omitempty"` + MaintenanceInfo *LoginMaintenanceInfo `json:"maintenance_info,omitempty"` + PendingAgreements []*TenantLoginPendingAgreement `json:"pending_agreements,omitempty"` // 待接受协议列表 +} + +// TenantLoginPendingAgreement 登录时返回的待接受协议信息 +type TenantLoginPendingAgreement struct { + Id int64 `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Version string `json:"version"` } // LoginMaintenanceInfo 维护模式信息 diff --git a/internal/consts/consts.go b/internal/consts/consts.go index c4e419b..e714de0 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -198,6 +198,12 @@ const ( // Username validation CodeInvalidUsername = 10089 + // Agreement errors + CodeAgreementNotFound = 10092 + CodeAgreementNotDraft = 10093 + CodeAgreementVersionExists = 10094 + CodeAgreementCodeInvalid = 10095 + // Demo mode CodeDemoModeRestricted = 10403 @@ -332,6 +338,12 @@ const ( // Username validation MsgInvalidUsername = "用户名仅支持英文字母和数字,不能为纯数字" + + // Agreement errors + MsgAgreementNotFound = "协议版本不存在" + MsgAgreementNotDraft = "仅草稿状态的协议可修改或删除" + MsgAgreementVersionExists = "该协议类型的此版本号已存在" + MsgAgreementCodeInvalid = "协议标识码无效" ) // Demo mode messages @@ -354,3 +366,15 @@ const ( MsgUpdateRollbackFailed = "回滚失败" MsgUpdateNotSupported = "当前部署模式不支持自动更新" ) + +// Agreement code constants +const ( + AgreementCodeTerms = "terms" // 用户协议/服务条款 + AgreementCodePrivacy = "privacy" // 隐私政策 +) + +// ValidAgreementCodes 有效协议标识码集合(创建时校验) +var ValidAgreementCodes = map[string]string{ + AgreementCodeTerms: "用户协议", + AgreementCodePrivacy: "隐私政策", +} diff --git a/internal/controller/admin/admin_v1_agreement.go b/internal/controller/admin/admin_v1_agreement.go new file mode 100644 index 0000000..088e529 --- /dev/null +++ b/internal/controller/admin/admin_v1_agreement.go @@ -0,0 +1,36 @@ +package admin + +import ( + "context" + + "github.com/qianfree/team-api/api/admin/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) AgreementCreate(ctx context.Context, req *v1.AgreementCreateReq) (res *v1.AgreementCreateRes, err error) { + return service.Admin().CreateAgreement(ctx, req) +} +func (c *ControllerV1) AgreementList(ctx context.Context, req *v1.AgreementListReq) (res *v1.AgreementListRes, err error) { + return service.Admin().ListAgreements(ctx, req) +} +func (c *ControllerV1) AgreementGet(ctx context.Context, req *v1.AgreementGetReq) (res *v1.AgreementGetRes, err error) { + return service.Admin().GetAgreement(ctx, req) +} +func (c *ControllerV1) AgreementUpdate(ctx context.Context, req *v1.AgreementUpdateReq) (res *v1.AgreementUpdateRes, err error) { + return service.Admin().UpdateAgreement(ctx, req) +} +func (c *ControllerV1) AgreementDelete(ctx context.Context, req *v1.AgreementDeleteReq) (res *v1.AgreementDeleteRes, err error) { + return service.Admin().DeleteAgreement(ctx, req) +} +func (c *ControllerV1) AgreementPublish(ctx context.Context, req *v1.AgreementPublishReq) (res *v1.AgreementPublishRes, err error) { + return service.Admin().PublishAgreement(ctx, req) +} +func (c *ControllerV1) AgreementAcceptanceList(ctx context.Context, req *v1.AgreementAcceptanceListReq) (res *v1.AgreementAcceptanceListRes, err error) { + return service.Admin().ListAgreementAcceptances(ctx, req) +} +func (c *ControllerV1) AdminAgreementPending(ctx context.Context, req *v1.AdminAgreementPendingReq) (res *v1.AdminAgreementPendingRes, err error) { + return service.Admin().ListAdminPendingAgreements(ctx, req) +} +func (c *ControllerV1) AdminAgreementAccept(ctx context.Context, req *v1.AdminAgreementAcceptReq) (res *v1.AdminAgreementAcceptRes, err error) { + return service.Admin().AcceptAdminAgreements(ctx, req) +} diff --git a/internal/controller/tenant/tenant_v1_agreement.go b/internal/controller/tenant/tenant_v1_agreement.go new file mode 100644 index 0000000..ed0a1c6 --- /dev/null +++ b/internal/controller/tenant/tenant_v1_agreement.go @@ -0,0 +1,21 @@ +package tenant + +import ( + "context" + + "github.com/qianfree/team-api/api/tenant/v1" + "github.com/qianfree/team-api/internal/service" +) + +func (c *ControllerV1) TenantAgreementPending(ctx context.Context, req *v1.TenantAgreementPendingReq) (res *v1.TenantAgreementPendingRes, err error) { + return service.Tenant().ListTenantPendingAgreements(ctx, req) +} +func (c *ControllerV1) TenantAgreementAccept(ctx context.Context, req *v1.TenantAgreementAcceptReq) (res *v1.TenantAgreementAcceptRes, err error) { + return service.Tenant().AcceptTenantAgreements(ctx, req) +} +func (c *ControllerV1) AgreementCurrentList(ctx context.Context, req *v1.AgreementCurrentListReq) (res *v1.AgreementCurrentListRes, err error) { + return service.Tenant().ListCurrentAgreements(ctx, req) +} +func (c *ControllerV1) AgreementCurrentGet(ctx context.Context, req *v1.AgreementCurrentGetReq) (res *v1.AgreementCurrentGetRes, err error) { + return service.Tenant().GetCurrentAgreementByCode(ctx, req) +} diff --git a/internal/dao/internal/sys_agreement_acceptances.go b/internal/dao/internal/sys_agreement_acceptances.go new file mode 100644 index 0000000..3cede7e --- /dev/null +++ b/internal/dao/internal/sys_agreement_acceptances.go @@ -0,0 +1,86 @@ +// ========================================================================== +// 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" +) + +// SysAgreementAcceptancesDao is the data access object for the table sys_agreement_acceptances. +type SysAgreementAcceptancesDao 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 SysAgreementAcceptancesColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// SysAgreementAcceptancesColumns defines and stores column names for the table sys_agreement_acceptances. +type SysAgreementAcceptancesColumns struct { + Id string // + AgreementId string // 关联协议版本ID + UserType string // 用户类型 + UserId string // 用户ID + IpAddress string // IP地址 + UserAgent string // User-Agent + CreatedAt string // +} + +// sysAgreementAcceptancesColumns holds the columns for the table sys_agreement_acceptances. +var sysAgreementAcceptancesColumns = SysAgreementAcceptancesColumns{ + Id: "id", + AgreementId: "agreement_id", + UserType: "user_type", + UserId: "user_id", + IpAddress: "ip_address", + UserAgent: "user_agent", + CreatedAt: "created_at", +} + +// NewSysAgreementAcceptancesDao creates and returns a new DAO object for table data access. +func NewSysAgreementAcceptancesDao(handlers ...gdb.ModelHandler) *SysAgreementAcceptancesDao { + return &SysAgreementAcceptancesDao{ + group: "default", + table: "sys_agreement_acceptances", + columns: sysAgreementAcceptancesColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *SysAgreementAcceptancesDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *SysAgreementAcceptancesDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *SysAgreementAcceptancesDao) Columns() SysAgreementAcceptancesColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *SysAgreementAcceptancesDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. +func (dao *SysAgreementAcceptancesDao) 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. +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 new file mode 100644 index 0000000..c7559ec --- /dev/null +++ b/internal/dao/internal/sys_agreements.go @@ -0,0 +1,98 @@ +// ========================================================================== +// 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" +) + +// SysAgreementsDao is the data access object for the table sys_agreements. +type SysAgreementsDao 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 SysAgreementsColumns // columns contains all the column names of Table for convenient usage. + handlers []gdb.ModelHandler // handlers for customized model modification. +} + +// SysAgreementsColumns defines and stores column names for the table sys_agreements. +type SysAgreementsColumns struct { + Id string // + Code string // 协议标识码 + Version string // 版本号 + Title string // 协议标题 + Content string // 协议正文(Markdown) + Summary string // 版本变更摘要 + Status string // 状态 + IsCurrent string // 是否为当前生效版本 + ForceAccept string // 是否强制用户接受 + PublishedAt string // 发布时间 + CreatedBy string // 创建的管理员ID + CreatedAt string // + UpdatedAt string // +} + +// sysAgreementsColumns holds the columns for the table sys_agreements. +var sysAgreementsColumns = SysAgreementsColumns{ + Id: "id", + Code: "code", + Version: "version", + Title: "title", + Content: "content", + Summary: "summary", + Status: "status", + IsCurrent: "is_current", + ForceAccept: "force_accept", + PublishedAt: "published_at", + CreatedBy: "created_by", + CreatedAt: "created_at", + UpdatedAt: "updated_at", +} + +// NewSysAgreementsDao creates and returns a new DAO object for table data access. +func NewSysAgreementsDao(handlers ...gdb.ModelHandler) *SysAgreementsDao { + return &SysAgreementsDao{ + group: "default", + table: "sys_agreements", + columns: sysAgreementsColumns, + handlers: handlers, + } +} + +// DB retrieves and returns the underlying raw database management object of the current DAO. +func (dao *SysAgreementsDao) DB() gdb.DB { + return g.DB(dao.group) +} + +// Table returns the table name of the current DAO. +func (dao *SysAgreementsDao) Table() string { + return dao.table +} + +// Columns returns all column names of the current DAO. +func (dao *SysAgreementsDao) Columns() SysAgreementsColumns { + return dao.columns +} + +// Group returns the database configuration group name of the current DAO. +func (dao *SysAgreementsDao) Group() string { + return dao.group +} + +// Ctx creates and returns a Model for the current DAO. +func (dao *SysAgreementsDao) 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. +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/sys_agreement_acceptances.go b/internal/dao/sys_agreement_acceptances.go new file mode 100644 index 0000000..9a23b2c --- /dev/null +++ b/internal/dao/sys_agreement_acceptances.go @@ -0,0 +1,19 @@ +// ================================================================================= +// 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" +) + +// sysAgreementAcceptancesDao is the data access object for the table sys_agreement_acceptances. +type sysAgreementAcceptancesDao struct { + *internal.SysAgreementAcceptancesDao +} + +var ( + // SysAgreementAcceptances is a globally accessible object for table sys_agreement_acceptances operations. + SysAgreementAcceptances = sysAgreementAcceptancesDao{internal.NewSysAgreementAcceptancesDao()} +) diff --git a/internal/dao/sys_agreements.go b/internal/dao/sys_agreements.go new file mode 100644 index 0000000..5a89916 --- /dev/null +++ b/internal/dao/sys_agreements.go @@ -0,0 +1,19 @@ +// ================================================================================= +// 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" +) + +// sysAgreementsDao is the data access object for the table sys_agreements. +type sysAgreementsDao struct { + *internal.SysAgreementsDao +} + +var ( + // SysAgreements is a globally accessible object for table sys_agreements operations. + SysAgreements = sysAgreementsDao{internal.NewSysAgreementsDao()} +) diff --git a/internal/logic/admin/agreement.go b/internal/logic/admin/agreement.go new file mode 100644 index 0000000..1a27e4c --- /dev/null +++ b/internal/logic/admin/agreement.go @@ -0,0 +1,263 @@ +package admin + +import ( + "context" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + + v1 "github.com/qianfree/team-api/api/admin/v1" + "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/model/do" +) + +// CreateAgreement 创建协议版本 +func (s *sAdmin) CreateAgreement(ctx context.Context, req *v1.AgreementCreateReq) (*v1.AgreementCreateRes, error) { + if _, ok := consts.ValidAgreementCodes[req.Code]; !ok { + return nil, common.NewBusinessError(consts.CodeAgreementCodeInvalid, consts.MsgAgreementCodeInvalid) + } + + // 检查 code+version 唯一性 + count, err := dao.SysAgreements.Ctx(ctx). + Where("code", req.Code). + Where("version", req.Version). + Count() + if err != nil { + return nil, err + } + if count > 0 { + return nil, common.NewBusinessError(consts.CodeAgreementVersionExists, consts.MsgAgreementVersionExists) + } + + forceAccept := true + if req.ForceAccept != nil { + forceAccept = *req.ForceAccept + } + + result, err := dao.SysAgreements.Ctx(ctx).Data(do.SysAgreements{ + Code: req.Code, + Version: req.Version, + Title: req.Title, + Content: req.Content, + Summary: req.Summary, + Status: "draft", + IsCurrent: false, + ForceAccept: forceAccept, + CreatedBy: common.GetCtxUserID(ctx), + }).Insert() + if err != nil { + return nil, err + } + + id, _ := result.LastInsertId() + return &v1.AgreementCreateRes{Id: id}, nil +} + +// ListAgreements 协议版本列表 +func (s *sAdmin) ListAgreements(ctx context.Context, req *v1.AgreementListReq) (*v1.AgreementListRes, error) { + page, pageSize := common.NormalizePagination(req.Page, req.PageSize) + + query := dao.SysAgreements.Ctx(ctx) + if req.Code != "" { + query = query.Where("code", req.Code) + } + if req.Status != "" { + query = query.Where("status", req.Status) + } + + var total int + rows := make([]*v1.AgreementItem, 0) + err := query.OrderDesc("created_at"). + Page(page, pageSize). + ScanAndCount(&rows, &total, false) + if err != nil { + return nil, err + } + + return &v1.AgreementListRes{ + List: rows, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +// GetAgreement 协议版本详情 +func (s *sAdmin) GetAgreement(ctx context.Context, req *v1.AgreementGetReq) (*v1.AgreementGetRes, error) { + var res *v1.AgreementGetRes + err := dao.SysAgreements.Ctx(ctx).Where("id", req.Id).Scan(&res) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if res == nil { + return nil, common.NewBusinessError(consts.CodeAgreementNotFound, consts.MsgAgreementNotFound) + } + return res, nil +} + +// UpdateAgreement 更新协议版本(仅 draft) +func (s *sAdmin) UpdateAgreement(ctx context.Context, req *v1.AgreementUpdateReq) (*v1.AgreementUpdateRes, error) { + var agr *struct { + Id int64 `json:"id"` + Status string `json:"status"` + } + err := dao.SysAgreements.Ctx(ctx).Where("id", req.Id).Scan(&agr) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if agr == nil { + return nil, common.NewBusinessError(consts.CodeAgreementNotFound, consts.MsgAgreementNotFound) + } + if agr.Status != "draft" { + return nil, common.NewBusinessError(consts.CodeAgreementNotDraft, consts.MsgAgreementNotDraft) + } + + data := do.SysAgreements{ + Title: req.Title, + Content: req.Content, + Summary: req.Summary, + } + if req.ForceAccept != nil { + data.ForceAccept = *req.ForceAccept + } + + _, err = dao.SysAgreements.Ctx(ctx). + Where("id", req.Id). + Data(data). + Update() + if err != nil { + return nil, err + } + + return &v1.AgreementUpdateRes{}, nil +} + +// DeleteAgreement 删除协议版本(仅 draft) +func (s *sAdmin) DeleteAgreement(ctx context.Context, req *v1.AgreementDeleteReq) (*v1.AgreementDeleteRes, error) { + var agr *struct { + Id int64 `json:"id"` + Status string `json:"status"` + } + err := dao.SysAgreements.Ctx(ctx).Where("id", req.Id).Scan(&agr) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if agr == nil { + return nil, common.NewBusinessError(consts.CodeAgreementNotFound, consts.MsgAgreementNotFound) + } + if agr.Status != "draft" { + return nil, common.NewBusinessError(consts.CodeAgreementNotDraft, consts.MsgAgreementNotDraft) + } + + _, err = dao.SysAgreements.Ctx(ctx).Where("id", req.Id).Delete() + if err != nil { + return nil, err + } + + return &v1.AgreementDeleteRes{}, nil +} + +// PublishAgreement 发布协议版本 +func (s *sAdmin) PublishAgreement(ctx context.Context, req *v1.AgreementPublishReq) (*v1.AgreementPublishRes, error) { + var agr *struct { + Id int64 `json:"id"` + Code string `json:"code"` + Status string `json:"status"` + } + err := dao.SysAgreements.Ctx(ctx).Where("id", req.Id).Scan(&agr) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if agr == nil { + return nil, common.NewBusinessError(consts.CodeAgreementNotFound, consts.MsgAgreementNotFound) + } + if agr.Status == "published" { + return &v1.AgreementPublishRes{}, nil + } + if agr.Status != "draft" { + return nil, common.NewBusinessError(consts.CodeAgreementNotDraft, consts.MsgAgreementNotDraft) + } + + err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { + return common.PublishAgreementTx(ctx, tx, agr.Id, agr.Code) + }) + if err != nil { + return nil, err + } + + return &v1.AgreementPublishRes{}, nil +} + +// ListAgreementAcceptances 协议接受记录列表 +func (s *sAdmin) ListAgreementAcceptances(ctx context.Context, req *v1.AgreementAcceptanceListReq) (*v1.AgreementAcceptanceListRes, error) { + // 先检查协议是否存在 + var agr *struct { + Id int64 `json:"id"` + } + err := dao.SysAgreements.Ctx(ctx).Where("id", req.Id).Scan(&agr) + if err = common.IgnoreScanNoRows(err); err != nil { + return nil, err + } + if agr == nil { + return nil, common.NewBusinessError(consts.CodeAgreementNotFound, consts.MsgAgreementNotFound) + } + + page, pageSize := common.NormalizePagination(req.Page, req.PageSize) + + var total int + rows := make([]*v1.AgreementAcceptanceItem, 0) + err = dao.SysAgreementAcceptances.Ctx(ctx). + Where("agreement_id", req.Id). + OrderDesc("created_at"). + Page(page, pageSize). + ScanAndCount(&rows, &total, false) + if err != nil { + return nil, err + } + + return &v1.AgreementAcceptanceListRes{ + List: rows, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +// ListAdminPendingAgreements 当前管理员待接受的协议 +func (s *sAdmin) ListAdminPendingAgreements(ctx context.Context, req *v1.AdminAgreementPendingReq) (*v1.AdminAgreementPendingRes, error) { + userID := common.GetCtxUserID(ctx) + list, err := common.GetPendingAgreements(ctx, "admin", userID) + if err != nil { + return nil, err + } + + items := make([]*v1.PendingAgreementItem, 0, len(list)) + for _, a := range list { + items = append(items, &v1.PendingAgreementItem{ + Id: a.Id, + Code: a.Code, + Title: a.Title, + Version: a.Version, + Content: a.Content, + }) + } + + return &v1.AdminAgreementPendingRes{List: items}, nil +} + +// AcceptAdminAgreements 管理员接受协议 +func (s *sAdmin) AcceptAdminAgreements(ctx context.Context, req *v1.AdminAgreementAcceptReq) (*v1.AdminAgreementAcceptRes, error) { + userID := common.GetCtxUserID(ctx) + r := g.RequestFromCtx(ctx) + ipAddress := r.GetClientIp() + userAgent := r.GetHeader("User-Agent") + + err := common.AcceptAgreements(ctx, "admin", userID, req.AgreementIds, ipAddress, userAgent) + if err != nil { + return nil, err + } + + return &v1.AdminAgreementAcceptRes{}, nil +} diff --git a/internal/logic/admin/auth.go b/internal/logic/admin/auth.go index abd566e..4035b36 100644 --- a/internal/logic/admin/auth.go +++ b/internal/logic/admin/auth.go @@ -138,6 +138,22 @@ func (s *sAdmin) Login(ctx context.Context, req *v1.AdminLoginReq) (*v1.AdminLog res.User.DisplayName = user.DisplayName res.User.Role = user.Role + // 检查待接受协议 + if common.Config().GetBool(ctx, "agreement_enabled") { + if pending, err := common.GetPendingAgreements(ctx, "admin", user.Id); err == nil && len(pending) > 0 { + items := make([]*v1.LoginPendingAgreement, 0, len(pending)) + for _, a := range pending { + items = append(items, &v1.LoginPendingAgreement{ + Id: a.Id, + Code: a.Code, + Title: a.Title, + Version: a.Version, + }) + } + res.PendingAgreements = items + } + } + return res, nil } diff --git a/internal/logic/common/agreement.go b/internal/logic/common/agreement.go new file mode 100644 index 0000000..7c9a33b --- /dev/null +++ b/internal/logic/common/agreement.go @@ -0,0 +1,182 @@ +package common + +import ( + "context" + "fmt" + + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gtime" + "github.com/qianfree/team-api/internal/dao" + "github.com/qianfree/team-api/internal/model/do" +) + +// PendingAgreementInfo 待接受协议信息 +type PendingAgreementInfo struct { + Id int64 `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Version string `json:"version"` + Content string `json:"content"` +} + +// CurrentAgreementItem 当前生效协议摘要 +type CurrentAgreementItem struct { + Id int64 `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Version string `json:"version"` + ForceAccept bool `json:"force_accept"` + PublishedAt *gtime.Time `json:"published_at,omitempty"` +} + +// CurrentAgreementDetail 当前生效协议详情 +type CurrentAgreementDetail struct { + Id int64 `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Version string `json:"version"` + Content string `json:"content"` + ForceAccept bool `json:"force_accept"` + PublishedAt *gtime.Time `json:"published_at,omitempty"` +} + +// GetPendingAgreements 获取用户待接受的协议列表 +func GetPendingAgreements(ctx context.Context, userType string, userID int64) ([]*PendingAgreementInfo, error) { + rows := make([]*PendingAgreementInfo, 0) + err := dao.SysAgreements.Ctx(ctx). + Where("is_current", true). + Where("status", "published"). + Where("force_accept", true). + Where(fmt.Sprintf( + "id NOT IN (SELECT agreement_id FROM sys_agreement_acceptances WHERE user_type = '%s' AND user_id = %d)", + userType, userID, + )). + Scan(&rows) + if err != nil { + return nil, err + } + return rows, nil +} + +// HasPendingAgreements 快速检查是否有待接受协议 +func HasPendingAgreements(ctx context.Context, userType string, userID int64) bool { + count, err := dao.SysAgreements.Ctx(ctx). + Where("is_current", true). + Where("status", "published"). + Where("force_accept", true). + Where(fmt.Sprintf( + "id NOT IN (SELECT agreement_id FROM sys_agreement_acceptances WHERE user_type = '%s' AND user_id = %d)", + userType, userID, + )). + Count() + if err != nil { + return false + } + return count > 0 +} + +// AcceptAgreements 批量接受协议 +func AcceptAgreements(ctx context.Context, userType string, userID int64, agreementIDs []int64, ipAddress, userAgent string) error { + for _, aid := range agreementIDs { + var agr *struct { + Id int64 `json:"id"` + IsCurrent bool `json:"is_current"` + } + err := dao.SysAgreements.Ctx(ctx). + Where("id", aid). + Where("is_current", true). + Where("status", "published"). + Scan(&agr) + if err = IgnoreScanNoRows(err); err != nil { + return err + } + if agr == nil { + continue + } + + // 检查是否已接受(幂等) + existCount, err := dao.SysAgreementAcceptances.Ctx(ctx). + Where("agreement_id", aid). + Where("user_type", userType). + Where("user_id", userID). + Count() + if err != nil { + return err + } + if existCount > 0 { + continue + } + + _, err = dao.SysAgreementAcceptances.Ctx(ctx).Data(do.SysAgreementAcceptances{ + AgreementId: aid, + UserType: userType, + UserId: userID, + IpAddress: ipAddress, + UserAgent: userAgent, + }).Insert() + if err != nil { + return err + } + } + return nil +} + +// GetCurrentAgreements 获取所有当前生效的协议列表(公开) +func GetCurrentAgreements(ctx context.Context) ([]*CurrentAgreementItem, error) { + rows := make([]*CurrentAgreementItem, 0) + err := dao.SysAgreements.Ctx(ctx). + Where("is_current", true). + Where("status", "published"). + Fields("id, code, title, version, force_accept, published_at"). + OrderAsc("code"). + Scan(&rows) + if err != nil { + return nil, err + } + return rows, nil +} + +// GetCurrentAgreementByCode 按 code 获取当前生效协议详情(公开) +func GetCurrentAgreementByCode(ctx context.Context, code string) (*CurrentAgreementDetail, error) { + var row *CurrentAgreementDetail + err := dao.SysAgreements.Ctx(ctx). + Where("code", code). + Where("is_current", true). + Where("status", "published"). + Scan(&row) + if err = IgnoreScanNoRows(err); err != nil { + return nil, err + } + return row, nil +} + +// PublishAgreementTx 在事务中发布协议(旧 current 归档,新版本设为 current) +func PublishAgreementTx(ctx context.Context, tx gdb.TX, agreementID int64, code string) error { + // 旧 current 归档 + _, err := dao.SysAgreements.Ctx(ctx). + Where("code", code). + Where("is_current", true). + Where("id !=", agreementID). + Data(g.Map{ + "is_current": false, + "status": "archived", + "updated_at": gtime.Now(), + }). + Update() + if err != nil { + return err + } + + // 新版本发布 + _, err = dao.SysAgreements.Ctx(ctx). + Where("id", agreementID). + Data(g.Map{ + "status": "published", + "is_current": true, + "published_at": gtime.Now(), + "updated_at": gtime.Now(), + }). + Update() + return err +} diff --git a/internal/logic/common/settings_registry.go b/internal/logic/common/settings_registry.go index d3dc563..6f873e9 100644 --- a/internal/logic/common/settings_registry.go +++ b/internal/logic/common/settings_registry.go @@ -45,6 +45,7 @@ var Categories = []SettingCategory{ {Key: "channel", Label: "渠道配置", Icon: "channel", Order: 9}, {Key: "storage", Label: "存储配置", Icon: "cloud", Order: 10}, {Key: "data_governance", Label: "数据治理", Icon: "database", Order: 11}, + {Key: "agreement", Label: "用户协议", Icon: "document", Order: 12}, } // Registry is the central definition of all configuration items. @@ -254,6 +255,10 @@ var Registry = []SettingDef{ Label: "GDPR删除请求完成天数", Validation: "min:7,max:90"}, {Key: "file_retention_enabled", Type: SettingTypeBool, Default: "true", Category: "data_governance", Label: "启用文件保留期检查"}, + + // ── Agreement (用户协议) ── + {Key: "agreement_enabled", Type: SettingTypeBool, Default: "false", Category: "agreement", + Label: "启用用户协议", Description: "启用后,管理员可管理协议版本,用户登录时需接受协议", IsPublic: true}, } // registryMap is a lookup index for fast access by key. diff --git a/internal/logic/tenant/agreement.go b/internal/logic/tenant/agreement.go new file mode 100644 index 0000000..75e0dd7 --- /dev/null +++ b/internal/logic/tenant/agreement.go @@ -0,0 +1,90 @@ +package tenant + +import ( + "context" + + "github.com/gogf/gf/v2/frame/g" + + v1 "github.com/qianfree/team-api/api/tenant/v1" + "github.com/qianfree/team-api/internal/logic/common" +) + +// ListTenantPendingAgreements 当前租户用户待接受的协议 +func (s *sTenant) ListTenantPendingAgreements(ctx context.Context, req *v1.TenantAgreementPendingReq) (*v1.TenantAgreementPendingRes, error) { + userID := common.GetCtxUserID(ctx) + list, err := common.GetPendingAgreements(ctx, "tenant", userID) + if err != nil { + return nil, err + } + + items := make([]*v1.TenantPendingAgreementItem, 0, len(list)) + for _, a := range list { + items = append(items, &v1.TenantPendingAgreementItem{ + Id: a.Id, + Code: a.Code, + Title: a.Title, + Version: a.Version, + Content: a.Content, + }) + } + + return &v1.TenantAgreementPendingRes{List: items}, nil +} + +// AcceptTenantAgreements 租户用户接受协议 +func (s *sTenant) AcceptTenantAgreements(ctx context.Context, req *v1.TenantAgreementAcceptReq) (*v1.TenantAgreementAcceptRes, error) { + userID := common.GetCtxUserID(ctx) + r := g.RequestFromCtx(ctx) + ipAddress := r.GetClientIp() + userAgent := r.GetHeader("User-Agent") + + err := common.AcceptAgreements(ctx, "tenant", userID, req.AgreementIds, ipAddress, userAgent) + if err != nil { + return nil, err + } + + return &v1.TenantAgreementAcceptRes{}, nil +} + +// ListCurrentAgreements 获取所有当前生效的协议列表(公开) +func (s *sTenant) ListCurrentAgreements(ctx context.Context, req *v1.AgreementCurrentListReq) (*v1.AgreementCurrentListRes, error) { + list, err := common.GetCurrentAgreements(ctx) + if err != nil { + return nil, err + } + + items := make([]*v1.PublicAgreementItem, 0, len(list)) + for _, a := range list { + items = append(items, &v1.PublicAgreementItem{ + Id: a.Id, + Code: a.Code, + Title: a.Title, + Version: a.Version, + ForceAccept: a.ForceAccept, + PublishedAt: a.PublishedAt, + }) + } + + return &v1.AgreementCurrentListRes{List: items}, nil +} + +// GetCurrentAgreementByCode 按标识码获取当前协议详情(公开) +func (s *sTenant) GetCurrentAgreementByCode(ctx context.Context, req *v1.AgreementCurrentGetReq) (*v1.AgreementCurrentGetRes, error) { + detail, err := common.GetCurrentAgreementByCode(ctx, req.Code) + if err != nil { + return nil, err + } + if detail == nil { + return nil, common.NewNotFoundError("协议不存在") + } + + return &v1.AgreementCurrentGetRes{ + Id: detail.Id, + Code: detail.Code, + Title: detail.Title, + Version: detail.Version, + Content: detail.Content, + ForceAccept: detail.ForceAccept, + PublishedAt: detail.PublishedAt, + }, nil +} diff --git a/internal/logic/tenant/auth.go b/internal/logic/tenant/auth.go index d15ded8..7347928 100644 --- a/internal/logic/tenant/auth.go +++ b/internal/logic/tenant/auth.go @@ -205,6 +205,22 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1. res.User.Username = username res.User.Role = "owner" + // 检查待接受协议 + if common.Config().GetBool(ctx, "agreement_enabled") { + if pending, err := common.GetPendingAgreements(ctx, "tenant", ownerUserID); err == nil && len(pending) > 0 { + items := make([]*v1.TenantLoginPendingAgreement, 0, len(pending)) + for _, a := range pending { + items = append(items, &v1.TenantLoginPendingAgreement{ + Id: a.Id, + Code: a.Code, + Title: a.Title, + Version: a.Version, + }) + } + res.PendingAgreements = items + } + } + return res, nil } @@ -435,6 +451,22 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant } } + // 检查待接受协议 + if common.Config().GetBool(ctx, "agreement_enabled") { + if pending, err := common.GetPendingAgreements(ctx, "tenant", user.Id); err == nil && len(pending) > 0 { + items := make([]*v1.TenantLoginPendingAgreement, 0, len(pending)) + for _, a := range pending { + items = append(items, &v1.TenantLoginPendingAgreement{ + Id: a.Id, + Code: a.Code, + Title: a.Title, + Version: a.Version, + }) + } + res.PendingAgreements = items + } + } + return res, nil } diff --git a/internal/model/do/sys_agreement_acceptances.go b/internal/model/do/sys_agreement_acceptances.go new file mode 100644 index 0000000..c95e300 --- /dev/null +++ b/internal/model/do/sys_agreement_acceptances.go @@ -0,0 +1,22 @@ +// ================================================================================= +// 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" +) + +// 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 // + AgreementId any // 关联协议版本ID + UserType any // 用户类型:admin / tenant + UserId any // 用户ID + 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 new file mode 100644 index 0000000..2ff307f --- /dev/null +++ b/internal/model/do/sys_agreements.go @@ -0,0 +1,28 @@ +// ================================================================================= +// 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" +) + +// 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 // 版本号 + Title any // 协议标题 + Content any // 协议正文(Markdown) + Summary any // 版本变更摘要 + Status any // 状态:draft / published / archived + IsCurrent any // 是否为当前生效版本 + ForceAccept any // 是否强制用户接受 + PublishedAt *gtime.Time // 发布时间 + CreatedBy any // 创建的管理员ID + CreatedAt *gtime.Time // + UpdatedAt *gtime.Time // +} diff --git a/internal/model/entity/sys_agreement_acceptances.go b/internal/model/entity/sys_agreement_acceptances.go new file mode 100644 index 0000000..40babeb --- /dev/null +++ b/internal/model/entity/sys_agreement_acceptances.go @@ -0,0 +1,20 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// 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"` +} diff --git a/internal/model/entity/sys_agreements.go b/internal/model/entity/sys_agreements.go new file mode 100644 index 0000000..836147d --- /dev/null +++ b/internal/model/entity/sys_agreements.go @@ -0,0 +1,26 @@ +// ================================================================================= +// Code generated and maintained by GoFrame CLI tool. DO NOT EDIT. +// ================================================================================= + +package entity + +import ( + "github.com/gogf/gf/v2/os/gtime" +) + +// 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"` +} diff --git a/internal/service/admin.go b/internal/service/admin.go index e3a916d..6189379 100644 --- a/internal/service/admin.go +++ b/internal/service/admin.go @@ -27,6 +27,24 @@ type ( ResetUserPassword(ctx context.Context, req *v1.AdminUserResetPasswordReq) (*v1.AdminUserResetPasswordRes, error) // ExportUsers exports admin users to CSV or Excel. ExportUsers(ctx context.Context, req *v1.AdminUserExportReq) (*v1.AdminUserExportRes, error) + // CreateAgreement 创建协议版本 + CreateAgreement(ctx context.Context, req *v1.AgreementCreateReq) (*v1.AgreementCreateRes, error) + // ListAgreements 协议版本列表 + ListAgreements(ctx context.Context, req *v1.AgreementListReq) (*v1.AgreementListRes, error) + // GetAgreement 协议版本详情 + GetAgreement(ctx context.Context, req *v1.AgreementGetReq) (*v1.AgreementGetRes, error) + // UpdateAgreement 更新协议版本(仅 draft) + UpdateAgreement(ctx context.Context, req *v1.AgreementUpdateReq) (*v1.AgreementUpdateRes, error) + // DeleteAgreement 删除协议版本(仅 draft) + DeleteAgreement(ctx context.Context, req *v1.AgreementDeleteReq) (*v1.AgreementDeleteRes, error) + // PublishAgreement 发布协议版本 + PublishAgreement(ctx context.Context, req *v1.AgreementPublishReq) (*v1.AgreementPublishRes, error) + // ListAgreementAcceptances 协议接受记录列表 + ListAgreementAcceptances(ctx context.Context, req *v1.AgreementAcceptanceListReq) (*v1.AgreementAcceptanceListRes, error) + // ListAdminPendingAgreements 当前管理员待接受的协议 + ListAdminPendingAgreements(ctx context.Context, req *v1.AdminAgreementPendingReq) (*v1.AdminAgreementPendingRes, error) + // AcceptAdminAgreements 管理员接受协议 + AcceptAdminAgreements(ctx context.Context, req *v1.AdminAgreementAcceptReq) (*v1.AdminAgreementAcceptRes, error) // GetAuditConfig retrieves the global audit level from sys_options. GetAuditConfig(ctx context.Context, _ *v1.AuditConfigGetReq) (*v1.AuditConfigGetRes, error) // UpdateAuditConfig updates the global audit level. @@ -225,6 +243,7 @@ type ( // FetchOfficialPricing 拉取模型官方定价(来自 LiteLLM + models.dev 双数据源) FetchOfficialPricing(ctx context.Context, req *v1.PricingFetchOfficialReq) (*v1.PricingFetchOfficialRes, error) // FetchOfficialModelInfo 按模型名称拉取官方模型信息(上下文长度+能力特性) + // 按优先级尝试 LiteLLM → OpenRouter → BaseLLM,返回第一个匹配的结果 FetchOfficialModelInfo(ctx context.Context, req *v1.ModelFetchOfficialInfoReq) (*v1.ModelFetchOfficialInfoRes, error) // ExportModels exports model list to CSV or Excel. ExportModels(ctx context.Context, req *v1.ModelExportReq) (*v1.ModelExportRes, error) diff --git a/internal/service/tenant.go b/internal/service/tenant.go index 9c9cfd8..d34926a 100644 --- a/internal/service/tenant.go +++ b/internal/service/tenant.go @@ -13,6 +13,14 @@ import ( type ( ITenant interface { + // ListTenantPendingAgreements 当前租户用户待接受的协议 + ListTenantPendingAgreements(ctx context.Context, req *v1.TenantAgreementPendingReq) (*v1.TenantAgreementPendingRes, error) + // AcceptTenantAgreements 租户用户接受协议 + AcceptTenantAgreements(ctx context.Context, req *v1.TenantAgreementAcceptReq) (*v1.TenantAgreementAcceptRes, error) + // ListCurrentAgreements 获取所有当前生效的协议列表(公开) + ListCurrentAgreements(ctx context.Context, req *v1.AgreementCurrentListReq) (*v1.AgreementCurrentListRes, error) + // GetCurrentAgreementByCode 按标识码获取当前协议详情(公开) + GetCurrentAgreementByCode(ctx context.Context, req *v1.AgreementCurrentGetReq) (*v1.AgreementCurrentGetRes, error) // ApiKeyList 列出 API Keys,支持按类型过滤 ApiKeyList(ctx context.Context, req *v1.TenantApiKeyListReq) (*v1.TenantApiKeyListRes, error) // ApiKeyCreate 创建新的 API Key diff --git a/migrations/000009_add_user_agreements.sql b/migrations/000009_add_user_agreements.sql new file mode 100644 index 0000000..1c1a9cf --- /dev/null +++ b/migrations/000009_add_user_agreements.sql @@ -0,0 +1,79 @@ +-- +goose Up + +-- ============================================================ +-- sys_agreements — 用户协议/政策版本管理 +-- ============================================================ +CREATE TABLE sys_agreements ( + id BIGSERIAL PRIMARY KEY, + code VARCHAR(50) NOT NULL, + version VARCHAR(50) NOT NULL, + title VARCHAR(200) NOT NULL, + content TEXT NOT NULL, + summary VARCHAR(500) NOT NULL DEFAULT '', + status VARCHAR(20) NOT NULL DEFAULT 'draft', + is_current BOOLEAN NOT NULL DEFAULT FALSE, + force_accept BOOLEAN NOT NULL DEFAULT TRUE, + published_at TIMESTAMPTZ, + created_by BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX uk_sys_agreements_current + ON sys_agreements (code) WHERE is_current = TRUE; + +CREATE UNIQUE INDEX uk_sys_agreements_code_version + ON sys_agreements (code, version); + +CREATE INDEX idx_sys_agreements_code_status + ON sys_agreements (code, status); + +COMMENT ON TABLE sys_agreements IS '用户协议/政策版本管理'; +COMMENT ON COLUMN sys_agreements.id IS '主键ID'; +COMMENT ON COLUMN sys_agreements.code IS '协议标识码:terms(用户协议) / privacy(隐私政策)'; +COMMENT ON COLUMN sys_agreements.version IS '版本号,如 1.0、2.0'; +COMMENT ON COLUMN sys_agreements.title IS '协议标题'; +COMMENT ON COLUMN sys_agreements.content IS '协议正文(Markdown)'; +COMMENT ON COLUMN sys_agreements.summary IS '版本变更摘要'; +COMMENT ON COLUMN sys_agreements.status IS '状态:draft(草稿) / published(已发布) / archived(已归档)'; +COMMENT ON COLUMN sys_agreements.is_current IS '是否为该标识码的当前生效版本(每个code仅一条)'; +COMMENT ON COLUMN sys_agreements.force_accept IS '是否强制用户接受(true=登录后必须接受才能继续)'; +COMMENT ON COLUMN sys_agreements.published_at IS '发布时间'; +COMMENT ON COLUMN sys_agreements.created_by IS '创建的管理员ID'; +COMMENT ON COLUMN sys_agreements.created_at IS '创建时间'; +COMMENT ON COLUMN sys_agreements.updated_at IS '更新时间'; + +-- ============================================================ +-- sys_agreement_acceptances — 用户协议接受记录 +-- ============================================================ +CREATE TABLE sys_agreement_acceptances ( + id BIGSERIAL PRIMARY KEY, + agreement_id BIGINT NOT NULL, + user_type VARCHAR(20) NOT NULL, + user_id BIGINT NOT NULL, + ip_address VARCHAR(45) NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX uk_sys_agreement_acceptances_user + ON sys_agreement_acceptances (agreement_id, user_type, user_id); + +CREATE INDEX idx_sys_agreement_acceptances_user_lookup + ON sys_agreement_acceptances (user_type, user_id, agreement_id); + +CREATE INDEX idx_sys_agreement_acceptances_created_brin + ON sys_agreement_acceptances USING BRIN (created_at); + +COMMENT ON TABLE sys_agreement_acceptances IS '用户协议接受记录'; +COMMENT ON COLUMN sys_agreement_acceptances.id IS '主键ID'; +COMMENT ON COLUMN sys_agreement_acceptances.agreement_id IS '关联协议版本ID'; +COMMENT ON COLUMN sys_agreement_acceptances.user_type IS '用户类型:admin(管理员) / tenant(租户用户)'; +COMMENT ON COLUMN sys_agreement_acceptances.user_id IS '用户ID'; +COMMENT ON COLUMN sys_agreement_acceptances.ip_address IS '接受时的IP地址'; +COMMENT ON COLUMN sys_agreement_acceptances.user_agent IS '接受时的浏览器User-Agent'; +COMMENT ON COLUMN sys_agreement_acceptances.created_at IS '接受时间'; + +-- +goose Down +DROP TABLE IF EXISTS sys_agreement_acceptances; +DROP TABLE IF EXISTS sys_agreements; diff --git a/web/admin/src/router/admin.ts b/web/admin/src/router/admin.ts index c648564..3aeea58 100644 --- a/web/admin/src/router/admin.ts +++ b/web/admin/src/router/admin.ts @@ -228,6 +228,12 @@ const adminRoutes: RouteRecordRaw[] = [ component: () => import('@/views/operation/ChangelogsPage.vue'), meta: { title: '更新日志' }, }, + { + path: 'agreements', + name: 'AdminAgreements', + component: () => import('@/views/operation/AgreementsPage.vue'), + meta: { title: '用户协议' }, + }, { path: 'realtime', name: 'AdminRealtimeMonitor', diff --git a/web/admin/src/stores/auth.ts b/web/admin/src/stores/auth.ts index 3ad53da..c0b12e3 100644 --- a/web/admin/src/stores/auth.ts +++ b/web/admin/src/stores/auth.ts @@ -10,12 +10,20 @@ export interface AdminUser { role: string } +export interface PendingAgreement { + id: number + code: string + title: string + version: string +} + interface LoginResponse { access_token: string refresh_token: string expires_at: number user: AdminUser permissions: string[] + pending_agreements?: PendingAgreement[] } const STORE_KEY = 'admin_auth' @@ -26,6 +34,7 @@ export const useAuthStore = defineStore('admin-auth', () => { const expiresAt = ref(null) const user = ref(null) const permissions = ref([]) + const pendingAgreements = ref([]) const isLoggedIn = computed(() => !!token.value) @@ -89,11 +98,13 @@ export const useAuthStore = defineStore('admin-auth', () => { if (data.data?.totp_required) { return data.data } + pendingAgreements.value = data.data?.pending_agreements || [] applySession(data.data) return data.data } function applyTokensFrom2FA(loginData: any): void { + pendingAgreements.value = loginData?.pending_agreements || [] applySession(loginData) } @@ -133,12 +144,17 @@ export const useAuthStore = defineStore('admin-auth', () => { persist() }) + function clearPendingAgreements(): void { + pendingAgreements.value = [] + } + return { token, refreshToken, expiresAt, user, permissions, + pendingAgreements, isLoggedIn, isSuperAdmin, login, @@ -146,5 +162,6 @@ export const useAuthStore = defineStore('admin-auth', () => { logout, refreshTokens, loadFromStorage, + clearPendingAgreements, } }) diff --git a/web/admin/src/views/AdminLayout.vue b/web/admin/src/views/AdminLayout.vue index 9774247..de903ba 100644 --- a/web/admin/src/views/AdminLayout.vue +++ b/web/admin/src/views/AdminLayout.vue @@ -329,6 +329,7 @@ const menuGroups = [ { name: 'AdminAnnouncements', label: '公告管理', icon: IconMessage }, { name: 'AdminTickets', label: '工单管理', icon: IconCommand }, { name: 'AdminFeedback', label: '反馈管理', icon: IconMessage }, + { name: 'AdminAgreements', label: '用户协议', icon: IconFile }, ], }, { diff --git a/web/admin/src/views/LoginPage.vue b/web/admin/src/views/LoginPage.vue index ec4e8d5..28162b7 100644 --- a/web/admin/src/views/LoginPage.vue +++ b/web/admin/src/views/LoginPage.vue @@ -1,9 +1,11 @@ + + + + diff --git a/web/tenant/src/components/common/AgreementAcceptModal.vue b/web/tenant/src/components/common/AgreementAcceptModal.vue new file mode 100644 index 0000000..f18b569 --- /dev/null +++ b/web/tenant/src/components/common/AgreementAcceptModal.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/web/tenant/src/router/tenant.ts b/web/tenant/src/router/tenant.ts index 8de8845..42a90ad 100644 --- a/web/tenant/src/router/tenant.ts +++ b/web/tenant/src/router/tenant.ts @@ -32,6 +32,12 @@ const tenantRoutes: RouteRecordRaw[] = [ component: () => import('@/views/auth/ForgotPasswordPage.vue'), meta: { requiresAuth: false, title: '找回密码' }, }, + { + path: '/tenant/agreement/:code', + name: 'TenantAgreement', + component: () => import('@/views/auth/AgreementPage.vue'), + meta: { requiresAuth: false, title: '协议详情' }, + }, { path: '/tenant/join', name: 'TenantJoin', diff --git a/web/tenant/src/stores/tenant-auth.ts b/web/tenant/src/stores/tenant-auth.ts index 3ea0264..8f5cb61 100644 --- a/web/tenant/src/stores/tenant-auth.ts +++ b/web/tenant/src/stores/tenant-auth.ts @@ -15,6 +15,13 @@ export interface TenantUser { role: string } +export interface PendingAgreement { + id: number + code: string + title: string + version: string +} + interface AuthResponse { access_token: string refresh_token: string @@ -22,6 +29,7 @@ interface AuthResponse { tenant: TenantInfo user: TenantUser permissions?: string[] + pending_agreements?: PendingAgreement[] } export interface RegisterPayload { @@ -44,6 +52,7 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { const tenant = ref(null) const user = ref(null) const permissions = ref([]) + const pendingAgreements = ref([]) const isLoggedIn = computed(() => !!token.value) @@ -116,17 +125,21 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { if (data.data?.totp_required) { return data.data } + pendingAgreements.value = data.data?.pending_agreements || [] applySession(data.data) return data.data } function applyTokensFrom2FA(loginData: any): void { + pendingAgreements.value = loginData?.pending_agreements || [] applySession(loginData) } - async function register(payload: RegisterPayload): Promise { + async function register(payload: RegisterPayload): Promise { const { data } = await request.post<{ data: AuthResponse }>('/tenant/auth/register', payload) + pendingAgreements.value = data.data?.pending_agreements || [] applySession(data.data) + return data.data } async function logout(): Promise { @@ -166,6 +179,10 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { persist() }) + function clearPendingAgreements(): void { + pendingAgreements.value = [] + } + return { token, refreshToken, @@ -173,6 +190,7 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { tenant, user, permissions, + pendingAgreements, isLoggedIn, isOwner, login, @@ -181,5 +199,6 @@ export const useTenantAuthStore = defineStore('tenant-auth', () => { logout, refreshTokens, loadFromStorage, + clearPendingAgreements, } }) diff --git a/web/tenant/src/views/auth/AgreementPage.vue b/web/tenant/src/views/auth/AgreementPage.vue new file mode 100644 index 0000000..b5e0ec9 --- /dev/null +++ b/web/tenant/src/views/auth/AgreementPage.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/web/tenant/src/views/auth/LoginPage.vue b/web/tenant/src/views/auth/LoginPage.vue index e719029..b5fd48d 100644 --- a/web/tenant/src/views/auth/LoginPage.vue +++ b/web/tenant/src/views/auth/LoginPage.vue @@ -9,6 +9,7 @@ import AuthLayout from '@/components/layout/AuthLayout.vue' import Icon from '@/components/common/Icon.vue' import SlideCaptcha from '@/components/common/SlideCaptcha.vue' import Turnstile from '@/components/common/Turnstile.vue' +import AgreementAcceptModal from '@/components/common/AgreementAcceptModal.vue' const router = useRouter() const route = useRoute() @@ -42,6 +43,26 @@ const captchaRef = ref | null>(null) const emailInput = ref(null) const accountInput = ref(null) +// Pending agreements +const showAgreements = ref(false) + +function proceedAfterLogin() { + const pending = authStore.pendingAgreements + if (pending.length > 0) { + showAgreements.value = true + return + } + const redirect = (route.query.redirect as string) || '/tenant/dashboard' + router.push(redirect) +} + +function onAgreementsAccepted() { + authStore.clearPendingAgreements() + showAgreements.value = false + const redirect = (route.query.redirect as string) || '/tenant/dashboard' + router.push(redirect) +} + onMounted(async () => { nextTick(() => emailInput.value?.focus()) await fetchSettings() @@ -108,8 +129,7 @@ async function handleAdminLogin() { show2FA.value = true return } - const redirect = (route.query.redirect as string) || '/tenant/dashboard' - await router.push(redirect) + proceedAfterLogin() } catch (err) { const apiErr = extractApiError(err) if (apiErr?.code === 10058) { @@ -140,8 +160,7 @@ async function handleRamLogin() { show2FA.value = true return } - const redirect = (route.query.redirect as string) || '/tenant/dashboard' - await router.push(redirect) + proceedAfterLogin() } catch (err) { const apiErr = extractApiError(err) if (apiErr?.code === 10058) { @@ -164,8 +183,7 @@ async function handle2FAVerify() { code: totpCode.value, }, { _suppressErrorMsg: true } as any) authStore.applyTokensFrom2FA(res.data.data) - const redirect = (route.query.redirect as string) || '/tenant/dashboard' - await router.push(redirect) + proceedAfterLogin() } catch (e: any) { const apiErr = extractApiError(e) errorMsg.value = apiErr?.message || '验证失败' @@ -476,8 +494,19 @@ async function handleOAuthLogin(provider: string) { 立即创建

+

+ 服务条款 + · + 隐私政策 +

+ +