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
+
+