feat:添加用户协议文档管理功能

This commit is contained in:
chengqian committed 2026-06-11 17:49:55 +08:00
1 parent 6dc69944d1
commit a1f637fb2a
37 files changed
+2287 -17

No files matched your search

+9
View File
@@ -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)
+146
View File
@@ -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{}
+9
View File
@@ -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 管理后台登出请求
+4
View File
@@ -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)
+67
View File
@@ -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"`
}
+11 -1
View File
@@ -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 维护模式信息
+24
View File
@@ -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: "隐私政策",
}
@@ -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)
}
@@ -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)
}
@@ -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)
}
+98
View File
@@ -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)
}
+19
View File
@@ -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()}
)
+19
View File
@@ -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()}
)
+263
View File
@@ -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
}
+16
View File
@@ -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
}
+182
View File
@@ -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
}
@@ -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.
+90
View File
@@ -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
}
+32
View File
@@ -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
}
@@ -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 //
}
+28
View File
@@ -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 //
}
@@ -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"`
}
+26
View File
@@ -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"`
}
+19
View File
@@ -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)
+8
View File
@@ -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
+79
View File
@@ -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;
+6
View File
@@ -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',
+17
View File
@@ -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<number | null>(null)
const user = ref<AdminUser | null>(null)
const permissions = ref<string[]>([])
const pendingAgreements = ref<PendingAgreement[]>([])
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,
}
})
+1
View File
@@ -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 },
],
},
{
+198 -8
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ref, reactive, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { Message } from '@arco-design/web-vue'
import type { FormInstance } from '@arco-design/web-vue'
import { marked } from 'marked'
import { useAuthStore } from '@/stores/auth'
import type { PendingAgreement } from '@/stores/auth'
import { extractApiError } from '@/utils/request'
import request from '@/utils/request'
import SlideCaptcha from '@/components/SlideCaptcha.vue'
@@ -31,6 +33,70 @@ const totpLoading = ref(false)
const captcha = reactive({ captchaKey: '', captchaX: 0 })
const captchaRef = ref<InstanceType<typeof SlideCaptcha> | null>(null)
// Pending agreements state
const showAgreements = ref(false)
const agreements = ref<PendingAgreement[]>([])
const selectedAgreement = ref<PendingAgreement | null>(null)
const agreementContent = ref('')
const agreementLoading = ref(false)
const acceptLoading = ref(false)
const allChecked = ref(false)
const agreementContentHtml = computed(() => {
if (!agreementContent.value) return ''
return marked(agreementContent.value) as string
})
function proceedAfterLogin() {
const pending = authStore.pendingAgreements
if (pending.length > 0) {
agreements.value = pending
selectedAgreement.value = pending[0]
loadAgreementContent(pending[0])
showAgreements.value = true
return
}
Message.success('登录成功')
const redirect = (route.query.redirect as string) || '/admin'
router.push(redirect)
}
async function loadAgreementContent(item: PendingAgreement) {
selectedAgreement.value = item
agreementContent.value = ''
agreementLoading.value = true
try {
const res = await request.get(`/admin/agreements/${item.id}`)
agreementContent.value = res.data?.data?.content || ''
} catch {
agreementContent.value = '加载失败,请重试'
} finally {
agreementLoading.value = false
}
}
async function acceptAgreements() {
if (!allChecked.value) {
Message.warning('请先阅读并勾选同意所有协议')
return
}
acceptLoading.value = true
try {
await request.post('/admin/agreements/accept', {
agreement_ids: agreements.value.map(a => a.id),
})
authStore.clearPendingAgreements()
showAgreements.value = false
Message.success('登录成功')
const redirect = (route.query.redirect as string) || '/admin'
router.push(redirect)
} catch {
// error auto-displayed
} finally {
acceptLoading.value = false
}
}
async function handleLogin() {
try {
const errors = await formRef.value?.validate()
@@ -48,9 +114,7 @@ async function handleLogin() {
show2FA.value = true
return
}
Message.success('登录成功')
const redirect = (route.query.redirect as string) || '/admin'
router.push(redirect)
proceedAfterLogin()
} catch (err: any) {
const apiErr = extractApiError(err)
if (apiErr?.code === 10058) {
@@ -73,11 +137,8 @@ async function handle2FAVerify() {
provisional_token: provisionalToken.value,
code: totpCode.value,
})
// If we get here, code === 0 (interceptor rejects otherwise)
authStore.applyTokensFrom2FA(res.data.data)
Message.success('登录成功')
const redirect = (route.query.redirect as string) || '/admin'
router.push(redirect)
proceedAfterLogin()
} catch {
// error toast already shown by interceptor
} finally {
@@ -232,6 +293,60 @@ async function handle2FAVerify() {
<span>&copy; {{ new Date().getFullYear() }} qianfree. Licensed under AGPL-3.0.</span>
</div>
</div>
<!-- Pending Agreements Modal -->
<AModal
v-model:visible="showAgreements"
:closable="false"
:mask-closable="false"
:footer="false"
:width="680"
title-align="start"
>
<template #title>
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>请阅读并同意以下协议</span>
</div>
</template>
<!-- Agreement tabs -->
<div v-if="agreements.length > 1" class="agreement-tabs">
<div
v-for="item in agreements"
:key="item.id"
class="agreement-tab"
:class="{ 'agreement-tab--active': selectedAgreement?.id === item.id }"
@click="loadAgreementContent(item)"
>
{{ item.title }} ({{ item.version }})
</div>
</div>
<!-- Agreement content -->
<div class="agreement-modal-content">
<ASpin :loading="agreementLoading" style="width: 100%">
<div class="agreement-body" v-html="agreementContentHtml" />
</ASpin>
</div>
<!-- Accept checkbox + button -->
<div class="agreement-footer">
<ACheckbox v-model="allChecked">
我已阅读并同意以上{{ agreements.length > 1 ? '所有' : '' }}协议
</ACheckbox>
<AButton
type="primary"
:loading="acceptLoading"
:disabled="!allChecked"
@click="acceptAgreements"
>
同意并继续
</AButton>
</div>
</AModal>
</div>
</template>
@@ -476,4 +591,79 @@ async function handle2FAVerify() {
.mt-2 {
margin-top: 8px;
}
/* ===== Agreements Modal ===== */
.agreement-tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 8px;
}
.agreement-tab {
padding: 6px 14px;
border-radius: 6px;
font-size: 13px;
color: #64748b;
cursor: pointer;
transition: all 0.15s;
}
.agreement-tab:hover {
background: #f1f5f9;
color: #334155;
}
.agreement-tab--active {
background: #0d9488;
color: #fff;
}
.agreement-tab--active:hover {
background: #0d9488;
color: #fff;
}
.agreement-modal-content {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
max-height: 400px;
overflow-y: auto;
background: #f8fafc;
line-height: 1.8;
font-size: 14px;
color: #334155;
}
.agreement-body :deep(h1),
.agreement-body :deep(h2),
.agreement-body :deep(h3) {
margin-top: 16px;
margin-bottom: 8px;
color: #0f172a;
}
.agreement-body :deep(h1) { font-size: 20px; }
.agreement-body :deep(h2) { font-size: 17px; }
.agreement-body :deep(h3) { font-size: 15px; }
.agreement-body :deep(ul),
.agreement-body :deep(ol) {
padding-left: 20px;
}
.agreement-body :deep(p) {
margin-bottom: 8px;
}
.agreement-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
}
</style>
@@ -0,0 +1,355 @@
<script setup lang="ts">
import { ref, reactive, onMounted, h, computed } from 'vue'
import {
Tag, Button, Space, Message, Modal,
} from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue'
import { marked } from 'marked'
import PageHeader from '@/components/PageHeader.vue'
import request from '@/utils/request'
const loading = ref(false)
const data = ref<any[]>([])
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showPageSize: true, pageSizeOptions: [10, 20, 50] })
const codeFilter = ref<string | undefined>(undefined)
const statusFilter = ref<string | undefined>(undefined)
const codeOptions = [
{ label: '全部', value: '' },
{ label: '用户协议', value: 'terms' },
{ label: '隐私政策', value: 'privacy' },
]
const statusOptions = [
{ label: '全部', value: '' },
{ label: '草稿', value: 'draft' },
{ label: '已发布', value: 'published' },
{ label: '已归档', value: 'archived' },
]
const codeLabelMap: Record<string, string> = { terms: '用户协议', privacy: '隐私政策' }
const statusColorMap: Record<string, string | undefined> = { draft: undefined, published: 'green', archived: 'gray' }
const statusLabelMap: Record<string, string> = { draft: '草稿', published: '已发布', archived: '已归档' }
const columns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '协议类型', dataIndex: 'code', width: 110, render({ record }) {
return codeLabelMap[record.code] || record.code
}},
{ title: '版本', dataIndex: 'version', width: 90 },
{ title: '标题', dataIndex: 'title', width: 200, ellipsis: true, tooltip: true },
{ title: '状态', dataIndex: 'status', width: 90, render({ record }) {
const parts = [
h(Tag, { color: statusColorMap[record.status], size: 'small' }, () => statusLabelMap[record.status] || record.status),
]
if (record.is_current) {
parts.push(h(Tag, { color: 'arcoblue', size: 'small', style: 'margin-left:4px' }, () => '当前'))
}
return h(Space, { size: 4 }, () => parts)
}},
{ title: '强制接受', dataIndex: 'force_accept', width: 90, render({ record }) {
return record.force_accept ? '是' : '否'
}},
{ title: '发布时间', dataIndex: 'published_at', width: 160, render({ record }) { return record.published_at || '-' } },
{ title: '创建时间', dataIndex: 'created_at', width: 160 },
{
title: '操作', dataIndex: 'actions', width: 260, fixed: 'right',
render({ record }) {
return h(Space, { size: 'small' }, () => [
h(Button, { size: 'small', type: 'text', onClick: () => openEditModal(record) }, () => record.status === 'draft' ? '编辑' : '查看'),
record.status === 'draft' ? h(Button, { size: 'small', type: 'text', status: 'success', onClick: () => publishAgreement(record) }, () => '发布') : null,
h(Button, { size: 'small', type: 'text', onClick: () => openAcceptances(record) }, () => '接受记录'),
record.status === 'draft' ? h(Button, { size: 'small', type: 'text', status: 'danger', onClick: () => deleteAgreement(record) }, () => '删除') : null,
])
},
},
]
async function fetchList() {
loading.value = true
try {
const params: any = { page: pagination.current, page_size: pagination.pageSize }
if (codeFilter.value) params.code = codeFilter.value
if (statusFilter.value) params.status = statusFilter.value
const res = await request.get('/admin/agreements', { params })
const d = res.data?.data
data.value = d?.list || []
pagination.total = d?.total || 0
} catch {
// error auto-displayed by interceptor
} finally {
loading.value = false
}
}
// === Create / Edit Drawer ===
const showModal = ref(false)
const editingId = ref<number | null>(null)
const editingStatus = ref<string>('draft')
const formLoading = ref(false)
const form = reactive({
code: 'terms' as string,
version: '',
title: '',
content: '',
summary: '',
force_accept: true,
})
const isReadonly = computed(() => editingId.value !== null && editingStatus.value !== 'draft')
function resetForm() {
Object.assign(form, { code: 'terms', version: '', title: '', content: '', summary: '', force_accept: true })
}
function openCreateModal() {
editingId.value = null
editingStatus.value = 'draft'
resetForm()
showModal.value = true
}
async function openEditModal(row: any) {
editingId.value = row.id
editingStatus.value = row.status
// Fetch full detail (with content)
try {
const res = await request.get(`/admin/agreements/${row.id}`)
const d = res.data?.data
Object.assign(form, {
code: d.code || 'terms',
version: d.version || '',
title: d.title || '',
content: d.content || '',
summary: d.summary || '',
force_accept: d.force_accept ?? true,
})
} catch {
Object.assign(form, {
code: row.code || 'terms',
version: row.version || '',
title: row.title || '',
content: '',
summary: row.summary || '',
force_accept: row.force_accept ?? true,
})
}
showModal.value = true
}
async function handleSubmit() {
if (!form.version.trim()) { Message.warning('请输入版本号'); return }
if (!form.title.trim()) { Message.warning('请输入标题'); return }
if (!form.content.trim()) { Message.warning('请输入内容'); return }
formLoading.value = true
try {
if (editingId.value) {
await request.put(`/admin/agreements/${editingId.value}`, {
title: form.title,
content: form.content,
summary: form.summary,
force_accept: form.force_accept,
})
Message.success('更新成功')
} else {
await request.post('/admin/agreements', form)
Message.success('创建成功')
}
showModal.value = false
fetchList()
} catch {
// error auto-displayed
} finally {
formLoading.value = false
}
}
function publishAgreement(row: any) {
Modal.confirm({
title: '确认发布',
content: `确定要发布「${codeLabelMap[row.code] || row.code}」版本 ${row.version} 吗?发布后将替换当前生效版本,所有用户需重新接受。`,
okText: '确认发布',
cancelText: '取消',
onOk: async () => {
try {
await request.post(`/admin/agreements/${row.id}/publish`)
Message.success('发布成功')
fetchList()
} catch { /* auto-displayed */ }
},
})
}
function deleteAgreement(row: any) {
Modal.confirm({
title: '确认删除',
content: `确定要删除「${codeLabelMap[row.code] || row.code}」版本 ${row.version} 吗?此操作不可恢复。`,
okText: '确认删除',
cancelText: '取消',
onOk: async () => {
try {
await request.delete(`/admin/agreements/${row.id}`)
Message.success('已删除')
fetchList()
} catch { /* auto-displayed */ }
},
})
}
// === Acceptances Drawer ===
const showAcceptances = ref(false)
const acceptancesTitle = ref('')
const acceptancesLoading = ref(false)
const acceptancesData = ref<any[]>([])
const acceptancesPagination = reactive({ current: 1, pageSize: 20, total: 0 })
const currentAcceptanceId = ref<number | null>(null)
const acceptanceColumns: TableColumnData[] = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '用户类型', dataIndex: 'user_type', width: 100, render({ record }) {
return record.user_type === 'admin' ? '管理员' : '租户用户'
}},
{ title: '用户ID', dataIndex: 'user_id', width: 90 },
{ title: 'IP 地址', dataIndex: 'ip_address', width: 140 },
{ title: '接受时间', dataIndex: 'created_at', width: 180 },
]
function openAcceptances(row: any) {
currentAcceptanceId.value = row.id
acceptancesTitle.value = `${codeLabelMap[row.code] || row.code} ${row.version} - 接受记录`
acceptancesPagination.current = 1
showAcceptances.value = true
fetchAcceptances()
}
async function fetchAcceptances() {
if (!currentAcceptanceId.value) return
acceptancesLoading.value = true
try {
const params = { page: acceptancesPagination.current, page_size: acceptancesPagination.pageSize }
const res = await request.get(`/admin/agreements/${currentAcceptanceId.value}/acceptances`, { params })
const d = res.data?.data
acceptancesData.value = d?.list || []
acceptancesPagination.total = d?.total || 0
} catch {
// auto-displayed
} finally {
acceptancesLoading.value = false
}
}
// === Preview ===
const showPreview = ref(false)
const previewHtml = computed(() => {
if (!form.content) return ''
return marked(form.content) as string
})
onMounted(fetchList)
</script>
<template>
<div>
<PageHeader title="用户协议" description="管理用户协议与隐私政策,支持版本管理和发布">
<template #actions>
<AButton type="primary" @click="openCreateModal">创建协议版本</AButton>
</template>
</PageHeader>
<ACard :bordered="false">
<template #title>
<div class="flex items-center justify-between w-full">
<span>协议列表</span>
<div class="flex gap-2">
<ASelect v-model="codeFilter" :options="codeOptions" style="width: 130px" allow-clear placeholder="协议类型" @change="() => { pagination.current = 1; fetchList() }" />
<ASelect v-model="statusFilter" :options="statusOptions" style="width: 120px" allow-clear placeholder="状态筛选" @change="() => { pagination.current = 1; fetchList() }" />
</div>
</div>
</template>
<ATable :columns="columns" :data="data" :loading="loading" row-key="id" :scroll="{ x: 1400 }" :pagination="false" />
<div class="mt-4 flex justify-end">
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchList" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchList() }" />
</div>
</ACard>
<!-- Create / Edit Drawer -->
<ADrawer v-model:visible="showModal" :title="editingId ? (isReadonly ? '查看协议' : '编辑协议') : '创建协议版本'" :width="720" :mask-closable="false" :footer="!isReadonly" unmount-on-close>
<AForm :model="form" :auto-label-width="true" layout="vertical">
<div class="flex gap-4">
<AFormItem label="协议类型" required class="flex-1">
<ASelect v-model="form.code" :disabled="!!editingId">
<AOption value="terms">用户协议</AOption>
<AOption value="privacy">隐私政策</AOption>
</ASelect>
</AFormItem>
<AFormItem label="版本号" required class="flex-1">
<AInput v-model="form.version" placeholder="例如: 1.0" :disabled="!!editingId" />
</AFormItem>
</div>
<AFormItem label="标题" required>
<AInput v-model="form.title" placeholder="请输入协议标题" :disabled="isReadonly" />
</AFormItem>
<AFormItem label="变更摘要">
<AInput v-model="form.summary" placeholder="本版本的主要变更说明(可选)" :disabled="isReadonly" />
</AFormItem>
<AFormItem label="强制接受">
<ASwitch v-model="form.force_accept" :disabled="isReadonly" />
<span class="ml-2 text-gray-500 text-sm">开启后用户必须接受此协议才能继续使用</span>
</AFormItem>
<AFormItem label="协议正文" required>
<div class="flex gap-2 mb-2">
<AButton size="mini" type="text" @click="showPreview = !showPreview">
{{ showPreview ? '编辑' : '预览' }}
</AButton>
</div>
<ATextarea v-if="!showPreview" v-model="form.content" :auto-size="{ minRows: 12, maxRows: 30 }" placeholder="Markdown 格式的协议正文" :disabled="isReadonly" />
<div v-else class="agreement-preview" v-html="previewHtml" />
</AFormItem>
</AForm>
<template v-if="!isReadonly" #footer>
<Space>
<AButton @click="showModal = false">取消</AButton>
<AButton type="primary" :loading="formLoading" @click="handleSubmit">确认</AButton>
</Space>
</template>
</ADrawer>
<!-- Acceptances Drawer -->
<ADrawer v-model:visible="showAcceptances" :title="acceptancesTitle" :width="640" unmount-on-close>
<ATable :columns="acceptanceColumns" :data="acceptancesData" :loading="acceptancesLoading" row-key="id" :pagination="false" />
<div class="mt-4 flex justify-end">
<APagination v-model:current="acceptancesPagination.current" :total="acceptancesPagination.total" :page-size="acceptancesPagination.pageSize" @change="fetchAcceptances" />
</div>
</ADrawer>
</div>
</template>
<style scoped>
.agreement-preview {
border: 1px solid var(--color-border-2);
border-radius: 4px;
padding: 16px;
min-height: 200px;
max-height: 500px;
overflow-y: auto;
background: var(--color-fill-1);
line-height: 1.8;
font-size: 14px;
}
.agreement-preview :deep(h1),
.agreement-preview :deep(h2),
.agreement-preview :deep(h3) {
margin-top: 16px;
margin-bottom: 8px;
}
.agreement-preview :deep(ul),
.agreement-preview :deep(ol) {
padding-left: 20px;
}
.agreement-preview :deep(p) {
margin-bottom: 8px;
}
</style>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { marked } from 'marked'
import type { PendingAgreement } from '@/stores/tenant-auth'
import request from '@/utils/request'
import Icon from '@/components/common/Icon.vue'
const props = defineProps<{
show: boolean
agreements: PendingAgreement[]
}>()
const emit = defineEmits<{
accepted: []
}>()
const selectedAgreement = ref<PendingAgreement | null>(null)
const agreementContent = ref('')
const contentLoading = ref(false)
const acceptLoading = ref(false)
const allChecked = ref(false)
const contentHtml = computed(() => {
if (!agreementContent.value) return ''
return marked(agreementContent.value) as string
})
watch(() => props.show, (val) => {
if (val && props.agreements.length > 0) {
selectedAgreement.value = props.agreements[0]
loadContent(props.agreements[0])
allChecked.value = false
}
})
async function loadContent(item: PendingAgreement) {
selectedAgreement.value = item
agreementContent.value = ''
contentLoading.value = true
try {
const res = await request.get(`/tenant/agreements/current/${item.code}`)
agreementContent.value = res.data?.data?.content || ''
} catch {
agreementContent.value = '加载失败,请重试'
} finally {
contentLoading.value = false
}
}
async function handleAccept() {
if (!allChecked.value) return
acceptLoading.value = true
try {
await request.post('/tenant/agreements/accept', {
agreement_ids: props.agreements.map(a => a.id),
})
emit('accepted')
} catch {
// error auto-displayed by interceptor
} finally {
acceptLoading.value = false
}
}
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="modal-overlay">
<div class="modal-content" style="max-width: 640px">
<!-- Header -->
<div class="modal-header">
<div class="flex items-center gap-2">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-100">
<Icon name="document" size="md" class="text-amber-600" />
</div>
<h3 class="modal-title">请阅读并同意以下协议</h3>
</div>
</div>
<!-- Body -->
<div class="modal-body" style="padding: 0;">
<!-- Tabs -->
<div v-if="agreements.length > 1" class="flex gap-1 px-4 pt-4 sm:px-6 sm:pt-4">
<button
v-for="item in agreements"
:key="item.id"
type="button"
class="rounded-lg px-3 py-1.5 text-sm font-medium transition-all"
:class="selectedAgreement?.id === item.id
? 'bg-primary-500 text-white'
: 'text-gray-500 hover:bg-gray-100 hover:text-gray-700'"
@click="loadContent(item)"
>
{{ item.title }}
</button>
</div>
<!-- Content -->
<div class="px-4 py-4 sm:px-6">
<div class="agreement-scroll rounded-xl border border-gray-200 bg-gray-50 p-5">
<div v-if="contentLoading" class="flex items-center justify-center py-12">
<div class="spinner h-5 w-5 border-primary-500"></div>
</div>
<div v-else class="markdown-content" v-html="contentHtml" />
</div>
</div>
</div>
<!-- Footer -->
<div class="modal-footer">
<label class="flex items-center gap-2 cursor-pointer select-none flex-1">
<input
v-model="allChecked"
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500/30 transition-colors cursor-pointer"
/>
<span class="text-sm text-gray-600">
我已阅读并同意以上{{ agreements.length > 1 ? '所有' : '' }}协议
</span>
</label>
<button
type="button"
class="btn btn-primary"
:disabled="!allChecked || acceptLoading"
@click="handleAccept"
>
<div v-if="acceptLoading" class="spinner h-4 w-4 border-white"></div>
{{ acceptLoading ? '提交中...' : '同意并继续' }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.agreement-scroll {
max-height: 380px;
overflow-y: auto;
line-height: 1.8;
font-size: 14px;
color: #334155;
}
.agreement-scroll :deep(h1),
.agreement-scroll :deep(h2),
.agreement-scroll :deep(h3) {
margin-top: 16px;
margin-bottom: 8px;
color: #0f172a;
font-weight: 600;
}
.agreement-scroll :deep(h1) { font-size: 20px; }
.agreement-scroll :deep(h2) { font-size: 17px; }
.agreement-scroll :deep(h3) { font-size: 15px; }
.agreement-scroll :deep(ul),
.agreement-scroll :deep(ol) {
padding-left: 20px;
}
.agreement-scroll :deep(p) {
margin-bottom: 8px;
}
</style>
+6
View File
@@ -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',
+20 -1
View File
@@ -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<TenantInfo | null>(null)
const user = ref<TenantUser | null>(null)
const permissions = ref<string[]>([])
const pendingAgreements = ref<PendingAgreement[]>([])
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<void> {
async function register(payload: RegisterPayload): Promise<any> {
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<void> {
@@ -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,
}
})
+108
View File
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { marked } from 'marked'
import request from '@/utils/request'
import AuthLayout from '@/components/layout/AuthLayout.vue'
import Icon from '@/components/common/Icon.vue'
const route = useRoute()
const code = computed(() => route.params.code as string)
const title = ref('')
const version = ref('')
const content = ref('')
const loading = ref(true)
const error = ref('')
const contentHtml = computed(() => {
if (!content.value) return ''
return marked(content.value) as string
})
onMounted(async () => {
try {
const res = await request.get(`/tenant/agreements/current/${code.value}`)
const data = res.data?.data
if (data) {
title.value = data.title
version.value = data.version
content.value = data.content || ''
} else {
error.value = '未找到该协议'
}
} catch {
error.value = '加载协议内容失败'
} finally {
loading.value = false
}
})
</script>
<template>
<AuthLayout>
<div class="animate-slide-up">
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<div class="spinner h-6 w-6 border-primary-500"></div>
</div>
<!-- Error -->
<div v-else-if="error" class="text-center py-12">
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gray-100">
<Icon name="document" size="lg" class="text-gray-400" />
</div>
<p class="text-sm text-gray-500">{{ error }}</p>
</div>
<!-- Content -->
<div v-else>
<div class="mb-4">
<h2 class="text-xl font-bold text-gray-900">{{ title }}</h2>
<p v-if="version" class="mt-1 text-xs text-gray-400">版本 {{ version }}</p>
</div>
<div class="agreement-content rounded-xl border border-gray-200 bg-gray-50 p-5" v-html="contentHtml" />
</div>
</div>
<template #footer>
<p class="text-gray-500">
<router-link to="/tenant/login" class="text-primary-600 font-medium hover:text-primary-700 transition-colors">
返回登录
</router-link>
</p>
</template>
</AuthLayout>
</template>
<style scoped>
.agreement-content {
max-height: 500px;
overflow-y: auto;
line-height: 1.8;
font-size: 14px;
color: #334155;
}
.agreement-content :deep(h1),
.agreement-content :deep(h2),
.agreement-content :deep(h3) {
margin-top: 16px;
margin-bottom: 8px;
color: #0f172a;
font-weight: 600;
}
.agreement-content :deep(h1) { font-size: 20px; }
.agreement-content :deep(h2) { font-size: 17px; }
.agreement-content :deep(h3) { font-size: 15px; }
.agreement-content :deep(ul),
.agreement-content :deep(ol) {
padding-left: 20px;
}
.agreement-content :deep(p) {
margin-bottom: 8px;
}
</style>
+35 -6
View File
@@ -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<InstanceType<typeof SlideCaptcha> | null>(null)
const emailInput = ref<HTMLInputElement | null>(null)
const accountInput = ref<HTMLInputElement | null>(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) {
立即创建
</router-link>
</p>
<p class="mt-2 text-xs text-gray-400">
<router-link to="/tenant/agreement/terms" target="_blank" class="hover:text-gray-500 transition-colors">服务条款</router-link>
<span class="mx-1">·</span>
<router-link to="/tenant/agreement/privacy" target="_blank" class="hover:text-gray-500 transition-colors">隐私政策</router-link>
</p>
</template>
</AuthLayout>
<AgreementAcceptModal
:show="showAgreements"
:agreements="authStore.pendingAgreements"
@accepted="onAgreementsAccepted"
/>
</template>
<style scoped>
+34 -1
View File
@@ -7,6 +7,7 @@ import AuthLayout from '@/components/layout/AuthLayout.vue'
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
import Turnstile from '@/components/common/Turnstile.vue'
import Icon from '@/components/common/Icon.vue'
import AgreementAcceptModal from '@/components/common/AgreementAcceptModal.vue'
import request from '@/utils/request'
import { extractApiError } from '@/utils/request'
@@ -35,6 +36,24 @@ const userForm = reactive({
const orgErrors = reactive<Record<string, string>>({})
const userErrors = reactive<Record<string, string>>({})
// Pending agreements
const showAgreements = ref(false)
function proceedAfterRegister() {
const pending = authStore.pendingAgreements
if (pending.length > 0) {
showAgreements.value = true
return
}
router.push('/tenant/dashboard')
}
function onAgreementsAccepted() {
authStore.clearPendingAgreements()
showAgreements.value = false
router.push('/tenant/dashboard')
}
const stepTitles = ['组织信息', '管理员信息']
onMounted(async () => {
@@ -179,7 +198,7 @@ async function handleRegister() {
}
await authStore.register(payload)
router.push('/tenant/dashboard')
proceedAfterRegister()
} catch (err: any) {
const apiErr = extractApiError(err)
const msg = apiErr?.message || '注册失败'
@@ -400,6 +419,14 @@ async function handleRegister() {
<p v-if="userErrors.confirmPassword" class="input-error-text">{{ userErrors.confirmPassword }}</p>
</div>
<!-- Agreement Notice -->
<p class="text-xs text-gray-400 text-center">
注册即表示您同意我们的
<router-link to="/tenant/agreement/terms" target="_blank" class="text-primary-600 hover:text-primary-700 underline underline-offset-2">服务条款</router-link>
和
<router-link to="/tenant/agreement/privacy" target="_blank" class="text-primary-600 hover:text-primary-700 underline underline-offset-2">隐私政策</router-link>
</p>
<!-- Submit -->
<button type="submit" :disabled="loading" class="btn btn-primary btn-lg w-full">
<div v-if="loading" class="spinner h-4 w-4 border-white"></div>
@@ -417,4 +444,10 @@ async function handleRegister() {
</p>
</template>
</AuthLayout>
<AgreementAcceptModal
:show="showAgreements"
:agreements="authStore.pendingAgreements"
@accepted="onAgreementsAccepted"
/>
</template>