feat():learning后台管理项目初始化
This commit is contained in:
170
internal/services/README.md
Normal file
170
internal/services/README.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# 服务层
|
||||
|
||||
本模块负责业务逻辑的实现,提供各种业务服务。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 用户管理服务
|
||||
- 角色管理服务
|
||||
- 菜单管理服务
|
||||
- 日志管理服务
|
||||
- 统一的错误处理
|
||||
- 业务逻辑封装
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
services/
|
||||
├── user_service.go # 用户服务
|
||||
├── role_service.go # 角色服务
|
||||
├── menu_service.go # 菜单服务
|
||||
├── log_service.go # 日志服务
|
||||
└── README.md # 说明文档
|
||||
```
|
||||
|
||||
## 服务接口
|
||||
|
||||
### 用户服务 (UserService)
|
||||
|
||||
```go
|
||||
type UserService interface {
|
||||
Login(req *models.LoginRequest) (*models.LoginResponse, error)
|
||||
Create(req *models.UserCreateRequest) (*models.User, error)
|
||||
GetByID(id uint) (*models.User, error)
|
||||
Update(id uint, req *models.UserUpdateRequest) (*models.User, error)
|
||||
Delete(id uint) error
|
||||
List(req *models.UserListRequest) ([]models.User, int64, error)
|
||||
ChangePassword(id uint, req *models.UserChangePasswordRequest) error
|
||||
UpdateStatus(id uint, status int) error
|
||||
}
|
||||
```
|
||||
|
||||
### 角色服务 (RoleService)
|
||||
|
||||
```go
|
||||
type RoleService interface {
|
||||
Create(req *models.RoleCreateRequest) (*models.Role, error)
|
||||
GetByID(id uint) (*models.Role, error)
|
||||
Update(id uint, req *models.RoleUpdateRequest) (*models.Role, error)
|
||||
Delete(id uint) error
|
||||
List(req *models.RoleListRequest) ([]models.Role, int64, error)
|
||||
UpdateStatus(id uint, status int) error
|
||||
}
|
||||
```
|
||||
|
||||
### 菜单服务 (MenuService)
|
||||
|
||||
```go
|
||||
type MenuService interface {
|
||||
Create(req *models.MenuCreateRequest) (*models.Menu, error)
|
||||
GetByID(id uint) (*models.Menu, error)
|
||||
Update(id uint, req *models.MenuUpdateRequest) (*models.Menu, error)
|
||||
Delete(id uint) error
|
||||
List(req *models.MenuListRequest) ([]models.Menu, int64, error)
|
||||
GetTree() ([]models.Menu, error)
|
||||
UpdateStatus(id uint, status int) error
|
||||
UpdateSort(id uint, sort int) error
|
||||
}
|
||||
```
|
||||
|
||||
### 日志服务 (LogService)
|
||||
|
||||
```go
|
||||
type LogService interface {
|
||||
CreateLoginLog(log *models.LoginLog) error
|
||||
CreateOperationLog(log *models.OperationLog) error
|
||||
GetLoginLogs(req *models.LoginLogListRequest) ([]models.LoginLog, int64, error)
|
||||
GetOperationLogs(req *models.OperationLogListRequest) ([]models.OperationLog, int64, error)
|
||||
DeleteLoginLogs(beforeDate string) error
|
||||
DeleteOperationLogs(beforeDate string) error
|
||||
}
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 创建服务实例
|
||||
|
||||
```go
|
||||
// 创建用户服务
|
||||
userService := services.NewUserService(
|
||||
storage.NewUserStorage(),
|
||||
utils.NewJWTManager("secret-key"),
|
||||
logger,
|
||||
)
|
||||
|
||||
// 创建角色服务
|
||||
roleService := services.NewRoleService(
|
||||
storage.NewRoleStorage(),
|
||||
logger,
|
||||
)
|
||||
|
||||
// 创建菜单服务
|
||||
menuService := services.NewMenuService(
|
||||
storage.NewMenuStorage(),
|
||||
logger,
|
||||
)
|
||||
|
||||
// 创建日志服务
|
||||
logService := services.NewLogService(
|
||||
storage.NewLogStorage(),
|
||||
logger,
|
||||
)
|
||||
```
|
||||
|
||||
### 使用服务
|
||||
|
||||
```go
|
||||
// 用户登录
|
||||
loginResp, err := userService.Login(&models.LoginRequest{
|
||||
Username: "admin",
|
||||
Password: "password",
|
||||
})
|
||||
|
||||
// 创建用户
|
||||
user, err := userService.Create(&models.UserCreateRequest{
|
||||
Username: "newuser",
|
||||
Email: "newuser@example.com",
|
||||
Password: "password",
|
||||
Role: "user",
|
||||
})
|
||||
|
||||
// 获取用户列表
|
||||
users, total, err := userService.List(&models.UserListRequest{
|
||||
PageRequest: models.PageRequest{Page: 1, Size: 10},
|
||||
Username: "admin",
|
||||
})
|
||||
|
||||
// 获取菜单树
|
||||
menus, err := menuService.GetTree()
|
||||
|
||||
// 记录操作日志
|
||||
err := logService.CreateOperationLog(&models.OperationLog{
|
||||
UserID: 1,
|
||||
Username: "admin",
|
||||
Module: "user",
|
||||
Operation: "create",
|
||||
Method: "POST",
|
||||
Path: "/api/users",
|
||||
IP: "127.0.0.1",
|
||||
Status: 1,
|
||||
})
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
所有服务都遵循统一的错误处理模式:
|
||||
|
||||
- 业务逻辑错误返回具体的错误信息
|
||||
- 数据库错误记录日志并返回通用错误信息
|
||||
- 参数验证错误返回具体的验证信息
|
||||
|
||||
## 日志记录
|
||||
|
||||
服务层会自动记录关键操作的日志:
|
||||
|
||||
- 用户登录/登出
|
||||
- 用户创建/更新/删除
|
||||
- 角色创建/更新/删除
|
||||
- 菜单创建/更新/删除
|
||||
- 系统操作日志
|
||||
|
||||
70
internal/services/audit_log_service.go
Normal file
70
internal/services/audit_log_service.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditLogService 审计日志服务接口
|
||||
type AuditLogService interface {
|
||||
Create(req *models.CreateAuditLogRequest) error
|
||||
GetByID(id uint) (*models.AuditLog, error)
|
||||
List(req *models.AuditLogListRequest) (*models.AuditLogListResponse, error)
|
||||
}
|
||||
|
||||
type auditLogService struct {
|
||||
storage storage.AuditLogStorage
|
||||
}
|
||||
|
||||
// NewAuditLogService 创建审计日志服务实例
|
||||
func NewAuditLogService(storage storage.AuditLogStorage) AuditLogService {
|
||||
return &auditLogService{
|
||||
storage: storage,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建审计日志
|
||||
func (s *auditLogService) Create(req *models.CreateAuditLogRequest) error {
|
||||
log := &models.AuditLog{
|
||||
OperationType: req.OperationType,
|
||||
OperationTime: time.Now(),
|
||||
OperatorID: req.OperatorID,
|
||||
OperatorEmail: req.OperatorEmail,
|
||||
TargetType: req.TargetType,
|
||||
TargetID: req.TargetID,
|
||||
TargetEmail: req.TargetEmail,
|
||||
OperationDetails: req.OperationDetails,
|
||||
IPAddress: req.IPAddress,
|
||||
UserAgent: req.UserAgent,
|
||||
Status: req.Status,
|
||||
ErrorMessage: req.ErrorMessage,
|
||||
}
|
||||
|
||||
// 设置默认状态
|
||||
if log.Status == "" {
|
||||
log.Status = models.AuditLogStatusSuccess
|
||||
}
|
||||
|
||||
return s.storage.Create(log)
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取审计日志
|
||||
func (s *auditLogService) GetByID(id uint) (*models.AuditLog, error) {
|
||||
return s.storage.GetByID(id)
|
||||
}
|
||||
|
||||
// List 查询审计日志列表
|
||||
func (s *auditLogService) List(req *models.AuditLogListRequest) (*models.AuditLogListResponse, error) {
|
||||
logs, total, err := s.storage.List(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.AuditLogListResponse{
|
||||
List: logs,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
Size: req.Size,
|
||||
}, nil
|
||||
}
|
||||
443
internal/services/email_service.go
Normal file
443
internal/services/email_service.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"net/smtp"
|
||||
"time"
|
||||
|
||||
"github.com/jordan-wright/email"
|
||||
|
||||
"goalfymax-admin/internal/config"
|
||||
)
|
||||
|
||||
type EmailService struct {
|
||||
host string
|
||||
port int
|
||||
username string
|
||||
password string
|
||||
sender string
|
||||
inviteURLPrefix string
|
||||
}
|
||||
|
||||
// NewEmailService 创建邮件服务实例
|
||||
func NewEmailService() *EmailService {
|
||||
cfg := config.GetConfig()
|
||||
return &EmailService{
|
||||
host: cfg.Email.Host,
|
||||
port: cfg.Email.Port,
|
||||
username: cfg.Email.Username,
|
||||
password: cfg.Email.Password,
|
||||
sender: cfg.Email.Sender,
|
||||
inviteURLPrefix: cfg.Email.InviteURLPrefix,
|
||||
}
|
||||
}
|
||||
|
||||
// SendInviteCodeApprovalEmail 发送邀请码审批通过邮件
|
||||
func (s *EmailService) SendInviteCodeApprovalEmail(toEmail, inviteCode, language string, expiresAt *time.Time) error {
|
||||
var subject string
|
||||
if language == "en" {
|
||||
subject = "Your GoalfyAI Beta Access Invitation"
|
||||
} else {
|
||||
subject = "GoalfyAI 内测邀请函"
|
||||
}
|
||||
|
||||
// 构造邮件内容
|
||||
htmlContent := s.generateApprovalEmailHTML(inviteCode, language, expiresAt)
|
||||
|
||||
return s.sendEmail(toEmail, subject, htmlContent)
|
||||
}
|
||||
|
||||
// SendInviteCodeRejectionEmail 发送邀请码申请拒绝邮件
|
||||
func (s *EmailService) SendInviteCodeRejectionEmail(toEmail, rejectReason string) error {
|
||||
subject := "关于您的 GoalfyAI 申请"
|
||||
|
||||
// 构造邮件内容
|
||||
htmlContent := s.generateRejectionEmailHTML(rejectReason)
|
||||
|
||||
return s.sendEmail(toEmail, subject, htmlContent)
|
||||
}
|
||||
|
||||
// generateApprovalEmailHTML 生成审批通过的邮件HTML
|
||||
func (s *EmailService) generateApprovalEmailHTML(inviteCode, language string, expiresAt *time.Time) string {
|
||||
if language == "en" {
|
||||
return s.GenerateApprovalEmailEN(inviteCode, expiresAt)
|
||||
}
|
||||
return s.GenerateApprovalEmailZH(inviteCode, expiresAt)
|
||||
}
|
||||
|
||||
// formatExpiryTimeEN 格式化过期时间为英文显示(全部显示为小时,向上取整)
|
||||
func formatExpiryTimeEN(expiresAt *time.Time) string {
|
||||
if expiresAt == nil {
|
||||
return "until used"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if expiresAt.Before(now) {
|
||||
return "expired"
|
||||
}
|
||||
|
||||
duration := expiresAt.Sub(now)
|
||||
hours := int(math.Ceil(duration.Hours())) // 向上取整
|
||||
|
||||
if hours <= 0 {
|
||||
hours = 1 // 不足一小时算一小时
|
||||
}
|
||||
|
||||
if hours == 1 {
|
||||
return "1 hour"
|
||||
}
|
||||
return fmt.Sprintf("%d hours", hours)
|
||||
}
|
||||
|
||||
// formatExpiryTimeZH 格式化过期时间为中文显示(全部显示为小时,向上取整)
|
||||
func formatExpiryTimeZH(expiresAt *time.Time) string {
|
||||
if expiresAt == nil {
|
||||
return "永久有效"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if expiresAt.Before(now) {
|
||||
return "已过期"
|
||||
}
|
||||
|
||||
duration := expiresAt.Sub(now)
|
||||
hours := int(math.Ceil(duration.Hours())) // 向上取整
|
||||
|
||||
if hours <= 0 {
|
||||
hours = 1 // 不足一小时算一小时
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d小时", hours)
|
||||
}
|
||||
|
||||
// GenerateApprovalEmailEN 生成英文版审批通过邮件(导出用于测试)
|
||||
func (s *EmailService) GenerateApprovalEmailEN(inviteCode string, expiresAt *time.Time) string {
|
||||
expiryHours := formatExpiryTimeEN(expiresAt)
|
||||
|
||||
tmplStr := `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<p>Thank you again for your interest in GoalfyAI!</p>
|
||||
|
||||
<p>We're excited to let you know that your request for beta access has been approved.<br>
|
||||
You can now activate your GoalfyAI account using the link below:</p>
|
||||
|
||||
<p>👉 <a href="{{.InviteURL}}">Activate Your Account</a><br>
|
||||
<span style="color: #666; font-size: 14px;">(This link is valid for {{.ExpiryHours}})</span></p>
|
||||
|
||||
<p>With this invite, you'll be among the first to explore our intelligent task execution system—designed for long-range, professional workflows. We'd love to hear your feedback as we continue to refine the experience.</p>
|
||||
|
||||
<p>Need help getting started? Visit our website for tips, use cases, and product updates:<br>
|
||||
🌐 <a href="https://www.goalfyai.com">GoalfyAI.com</a></p>
|
||||
|
||||
<p>Thanks again for joining us on this journey.<br>
|
||||
Let's build the future of intelligent tasks—together.</p>
|
||||
|
||||
<p>Warm regards,<br>
|
||||
The GoalfyAI Team</p>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #ddd; margin: 40px 0;">
|
||||
|
||||
<p style="text-align: center; color: #999; font-size: 12px; line-height: 1.5;">
|
||||
This email is sent automatically. Please do not reply.<br>
|
||||
For any questions, please contact <a href="mailto:hi@goalfyai.com" style="color: #999;">hi@goalfyai.com</a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
tmpl, _ := template.New("approval_en").Parse(tmplStr)
|
||||
var buf bytes.Buffer
|
||||
tmpl.Execute(&buf, map[string]string{
|
||||
"InviteCode": inviteCode,
|
||||
"ExpiryHours": expiryHours,
|
||||
"InviteURL": s.inviteURLPrefix + inviteCode,
|
||||
})
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// GenerateApprovalEmailZH 生成中文版审批通过邮件(导出用于测试)
|
||||
func (s *EmailService) GenerateApprovalEmailZH(inviteCode string, expiresAt *time.Time) string {
|
||||
expiryHours := formatExpiryTimeZH(expiresAt)
|
||||
|
||||
tmplStr := `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="font-family: 'Microsoft YaHei', Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<p>感谢您对 GoalfyAI 的关注与支持!</p>
|
||||
|
||||
<p>我们很高兴通知您,您的内测申请已通过审核。<br>
|
||||
请通过以下链接激活您的 GoalfyAI 账户:</p>
|
||||
|
||||
<p>👉 <a href="{{.InviteURL}}">点击激活账户</a><br>
|
||||
<span style="color: #666; font-size: 14px;">(该链接在 {{.ExpiryHours}} 内有效)</span></p>
|
||||
|
||||
<p>通过本次邀请,您将率先体验我们为长周期专业任务打造的智能任务系统。我们也非常欢迎您在使用过程中给予反馈,帮助我们持续优化产品体验。</p>
|
||||
|
||||
<p>如需了解更多使用建议、典型场景或最新进展,欢迎访问官网:<br>
|
||||
🌐 <a href="https://www.goalfyai.com">GoalfyAI.com</a></p>
|
||||
|
||||
<p>感谢您的加入,<br>
|
||||
让我们一同开启智能任务的新篇章!</p>
|
||||
|
||||
<p>此致,<br>
|
||||
GoalfyAI 团队</p>
|
||||
|
||||
|
||||
<hr style="border: none; border-top: 1px solid #ddd; margin: 40px 0;">
|
||||
|
||||
<p style="text-align: center; color: #999; font-size: 12px; line-height: 1.5;">
|
||||
本邮件为自动化发送,请勿回复。<br>
|
||||
如有疑问请联系 <a href="mailto:hi@goalfyai.com" style="color: #999;">hi@goalfyai.com</a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
tmpl, _ := template.New("approval_zh").Parse(tmplStr)
|
||||
var buf bytes.Buffer
|
||||
tmpl.Execute(&buf, map[string]string{
|
||||
"InviteCode": inviteCode,
|
||||
"ExpiryHours": expiryHours,
|
||||
"InviteURL": s.inviteURLPrefix + inviteCode,
|
||||
})
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// generateRejectionEmailHTML 生成申请拒绝的邮件HTML
|
||||
func (s *EmailService) generateRejectionEmailHTML(rejectReason string) string {
|
||||
if rejectReason == "" {
|
||||
rejectReason = "感谢您对 GoalfyAI 的关注。经过审核,您的账户申请暂未通过。"
|
||||
}
|
||||
|
||||
tmplStr := `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
background-color: #f5f5f5;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.email-wrapper {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.header {
|
||||
background-color: #000000;
|
||||
padding: 32px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.header-logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.content {
|
||||
padding: 40px;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 16px;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.message {
|
||||
font-size: 15px;
|
||||
color: #4a4a4a;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.reason-box {
|
||||
background-color: #fafafa;
|
||||
border-left: 3px solid #4a4a4a;
|
||||
padding: 20px 24px;
|
||||
margin: 24px 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.reason-box p {
|
||||
font-size: 15px;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.support-box {
|
||||
background-color: #fafafa;
|
||||
border-radius: 6px;
|
||||
padding: 20px 24px;
|
||||
margin-top: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
.support-box p {
|
||||
font-size: 14px;
|
||||
color: #4a4a4a;
|
||||
margin: 0;
|
||||
}
|
||||
.support-box a {
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer {
|
||||
background-color: #fafafa;
|
||||
padding: 32px 40px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
.footer-brand {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.footer-contact {
|
||||
font-size: 13px;
|
||||
color: #666666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.footer-contact a {
|
||||
color: #000000;
|
||||
text-decoration: none;
|
||||
}
|
||||
@media only screen and (max-width: 600px) {
|
||||
.content {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
.header {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
.reason-box {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-wrapper">
|
||||
<div class="header">
|
||||
<div class="header-logo">GoalfyAI</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="greeting">您好,</div>
|
||||
|
||||
<div class="message">
|
||||
感谢您对 GoalfyAI 的关注和申请。
|
||||
</div>
|
||||
|
||||
<div class="reason-box">
|
||||
<p>{{.RejectReason}}</p>
|
||||
</div>
|
||||
|
||||
<div class="message" style="margin-top: 24px;">
|
||||
我们期待未来有机会为您提供服务。
|
||||
</div>
|
||||
|
||||
<div class="support-box">
|
||||
<p>如有任何疑问,欢迎联系我们</p>
|
||||
<p style="margin-top: 8px;">
|
||||
<a href="mailto:goalfymax@goalfyai.com">goalfymax@goalfyai.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="footer-brand">GoalfyAI 团队</div>
|
||||
<div class="footer-contact">
|
||||
© 2025 GoalfyAI. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
tmpl, _ := template.New("rejection").Parse(tmplStr)
|
||||
var buf bytes.Buffer
|
||||
tmpl.Execute(&buf, map[string]string{
|
||||
"RejectReason": rejectReason,
|
||||
})
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// sendEmail 发送邮件的通用方法
|
||||
func (s *EmailService) sendEmail(toEmail, subject, htmlContent string) error {
|
||||
e := email.NewEmail()
|
||||
e.From = s.sender
|
||||
e.To = []string{toEmail}
|
||||
e.Subject = subject
|
||||
e.HTML = []byte(htmlContent)
|
||||
|
||||
// 创建SMTP认证
|
||||
auth := smtp.PlainAuth("", s.username, s.password, s.host)
|
||||
|
||||
// 配置TLS
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: s.host,
|
||||
}
|
||||
|
||||
// 发送邮件
|
||||
addr := fmt.Sprintf("%s:%d", s.host, s.port)
|
||||
|
||||
// 如果是465端口,使用SSL
|
||||
if s.port == 465 {
|
||||
return e.SendWithTLS(addr, auth, tlsConfig)
|
||||
}
|
||||
|
||||
// 否则使用STARTTLS
|
||||
return e.SendWithStartTLS(addr, auth, tlsConfig)
|
||||
}
|
||||
|
||||
// SendBatchEmails 批量发送邮件(异步)
|
||||
func (s *EmailService) SendBatchEmails(emails []string, subject, htmlContent string) []error {
|
||||
errors := make([]error, len(emails))
|
||||
for i, email := range emails {
|
||||
errors[i] = s.sendEmail(email, subject, htmlContent)
|
||||
// 避免发送过快
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
// TestConnection 测试邮件服务器连接
|
||||
func (s *EmailService) TestConnection() error {
|
||||
addr := fmt.Sprintf("%s:%d", s.host, s.port)
|
||||
|
||||
// 尝试连接SMTP服务器
|
||||
client, err := smtp.Dial(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to SMTP server: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
2513
internal/services/finance_service.go
Normal file
2513
internal/services/finance_service.go
Normal file
File diff suppressed because it is too large
Load Diff
321
internal/services/gateway_client.go
Normal file
321
internal/services/gateway_client.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"goalfymax-admin/pkg/utils"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"goalfymax-admin/internal/config"
|
||||
"goalfymax-admin/internal/models"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GatewayClient 网关客户端
|
||||
type GatewayClient struct {
|
||||
baseURL string
|
||||
timeout time.Duration
|
||||
logger *utils.Logger
|
||||
token string
|
||||
}
|
||||
|
||||
// NewGatewayClient 创建网关客户端
|
||||
func NewGatewayClient(baseURL string, timeout time.Duration, logger *utils.Logger) *GatewayClient {
|
||||
return &GatewayClient{
|
||||
baseURL: baseURL,
|
||||
timeout: timeout,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// acquireToken 从配置的登录接口获取新的网关 token(不做过期判定)
|
||||
func (c *GatewayClient) acquireToken() (string, error) {
|
||||
return "admin_control_0807", nil
|
||||
|
||||
cfg := config.GetConfig()
|
||||
loginURL := cfg.Gateway.Auth.LoginURL
|
||||
key := cfg.Gateway.Auth.Key
|
||||
payload, _ := json.Marshal(map[string]string{"key": key})
|
||||
req, err := http.NewRequest("POST", loginURL, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: c.timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("login status: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var out struct {
|
||||
Success bool `json:"success"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !out.Success || out.Token == "" {
|
||||
return "", fmt.Errorf("login failed: %s", string(body))
|
||||
}
|
||||
c.logger.Info("login succeeded", zap.String("token", out.Token))
|
||||
c.token = out.Token
|
||||
return c.token, nil
|
||||
}
|
||||
|
||||
// doWithAuth 发送请求,自动注入token;若401则重取token并重试一次
|
||||
func (c *GatewayClient) doWithAuth(req *http.Request) (*http.Response, error) {
|
||||
if c.token == "" {
|
||||
var err error
|
||||
if c.token, err = c.acquireToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
client := &http.Client{Timeout: c.timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
// 读尽响应体以复用连接
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if _, err := c.acquireToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 重试一次
|
||||
// 重新构建请求体(仅当是可重读的bytes.Buffer);这里假设上层构造的Body为bytes.Buffer或nil
|
||||
// 如果是一次性流,上层应改为传入可重读体
|
||||
if req.GetBody != nil {
|
||||
bodyRc, _ := req.GetBody()
|
||||
req.Body = bodyRc
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
return client.Do(req)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetQuotaHistory 获取配额历史数据
|
||||
func (c *GatewayClient) GetQuotaHistory(req *models.QuotaHistoryRequest) (*models.QuotaHistoryResponse, error) {
|
||||
// 构建请求URL
|
||||
url := fmt.Sprintf("%s/aigateway-admin/api/quotas/history", c.baseURL)
|
||||
|
||||
// 序列化请求数据
|
||||
jsonData, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
c.logger.Error("序列化请求数据失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("序列化请求数据失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建HTTP请求
|
||||
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
c.logger.Error("创建HTTP请求失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("创建HTTP请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
|
||||
// 发送请求
|
||||
c.logger.Debug("发送配额历史查询请求",
|
||||
zap.String("url", url),
|
||||
zap.String("data", string(jsonData)),
|
||||
)
|
||||
|
||||
resp, err := c.doWithAuth(httpReq)
|
||||
if err != nil {
|
||||
c.logger.Error("发送HTTP请求失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("发送HTTP请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.logger.Error("读取响应数据失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("读取响应数据失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查HTTP状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Error("网关返回错误状态码",
|
||||
zap.Int("status_code", resp.StatusCode),
|
||||
zap.String("response", string(body)),
|
||||
)
|
||||
return nil, fmt.Errorf("网关返回错误状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var response models.QuotaHistoryResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
c.logger.Error("解析响应数据失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("解析响应数据失败: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("配额历史查询成功",
|
||||
zap.Int("data_count", len(response.Data)),
|
||||
zap.Bool("success", response.Success),
|
||||
)
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// GetQuotaRules 获取配额规则列表(代理到网关),携带Authorization
|
||||
func (c *GatewayClient) GetQuotaRules(authToken string) (*models.QuotaRulesResponse, error) {
|
||||
url := fmt.Sprintf("%s/aigateway-admin/api/quotas/rules", c.baseURL)
|
||||
|
||||
httpReq, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
c.logger.Error("创建HTTP请求失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("创建HTTP请求失败: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Accept", "application/json")
|
||||
if authToken != "" {
|
||||
httpReq.Header.Set("Authorization", authToken)
|
||||
}
|
||||
|
||||
c.logger.Debug("请求配额规则列表", zap.String("url", url))
|
||||
resp, err := c.doWithAuth(httpReq)
|
||||
if err != nil {
|
||||
c.logger.Error("发送HTTP请求失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("发送HTTP请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
c.logger.Error("读取响应数据失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("读取响应数据失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Error("网关返回错误状态码", zap.Int("status_code", resp.StatusCode), zap.String("response", string(body)))
|
||||
return nil, fmt.Errorf("网关返回错误状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var response models.QuotaRulesResponse
|
||||
if err := json.Unmarshal(body, &response); err != nil {
|
||||
c.logger.Error("解析响应数据失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("解析响应数据失败: %w", err)
|
||||
}
|
||||
|
||||
c.logger.Info("获取配额规则成功")
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// CreateQuotaRule 创建配额规则(代理网关)
|
||||
func (c *GatewayClient) CreateQuotaRule(authToken string, body any) (*models.QuotaRulesResponse, error) {
|
||||
url := fmt.Sprintf("%s/aigateway-admin/api/quotas/rules", c.baseURL)
|
||||
payload, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if authToken != "" {
|
||||
req.Header.Set("Authorization", authToken)
|
||||
}
|
||||
resp, err := c.doWithAuth(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gateway status: %d %s", resp.StatusCode, string(b))
|
||||
}
|
||||
var out models.QuotaRulesResponse
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// UpdateQuotaRule 更新配额规则(代理网关)
|
||||
func (c *GatewayClient) UpdateQuotaRule(authToken string, id string, body any) (*models.QuotaRulesResponse, error) {
|
||||
url := fmt.Sprintf("%s/aigateway-admin/api/quotas/rules/%s", c.baseURL, id)
|
||||
payload, _ := json.Marshal(body)
|
||||
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if authToken != "" {
|
||||
req.Header.Set("Authorization", authToken)
|
||||
}
|
||||
resp, err := c.doWithAuth(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gateway status: %d %s", resp.StatusCode, string(b))
|
||||
}
|
||||
var out models.QuotaRulesResponse
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// DeleteQuotaRule 删除配额规则(代理网关)
|
||||
func (c *GatewayClient) DeleteQuotaRule(authToken string, id string) (*models.QuotaRulesResponse, error) {
|
||||
url := fmt.Sprintf("%s/aigateway-admin/api/quotas/rules/%s", c.baseURL, id)
|
||||
req, err := http.NewRequest("DELETE", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if authToken != "" {
|
||||
req.Header.Set("Authorization", authToken)
|
||||
}
|
||||
resp, err := c.doWithAuth(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gateway status: %d %s", resp.StatusCode, string(b))
|
||||
}
|
||||
var out models.QuotaRulesResponse
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查
|
||||
func (c *GatewayClient) HealthCheck() error {
|
||||
url := fmt.Sprintf("%s/aigateway-admin/health", c.baseURL)
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: c.timeout,
|
||||
}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
c.logger.Error("网关健康检查失败", zap.Error(err))
|
||||
return fmt.Errorf("网关健康检查失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Error("网关健康检查返回错误状态码", zap.Int("status_code", resp.StatusCode))
|
||||
return fmt.Errorf("网关健康检查返回错误状态码: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
c.logger.Info("网关健康检查成功")
|
||||
return nil
|
||||
}
|
||||
512
internal/services/goalfymax_user_service.go
Normal file
512
internal/services/goalfymax_user_service.go
Normal file
@@ -0,0 +1,512 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
"goalfymax-admin/pkg/redis"
|
||||
"goalfymax-admin/pkg/utils"
|
||||
"strconv"
|
||||
|
||||
goredis "github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type GoalfyMaxUserService interface {
|
||||
List(req *models.GoalfyMaxUserListRequest) ([]models.GoalfyMaxUser, int64, error)
|
||||
GetByID(id uint) (*models.GoalfyMaxUser, error)
|
||||
Create(req *models.GoalfyMaxUserCreateRequest) (*models.GoalfyMaxUser, error)
|
||||
Update(id uint, req *models.GoalfyMaxUserUpdateRequest) (*models.GoalfyMaxUser, error)
|
||||
Delete(id uint) error
|
||||
Ban(id uint, req *models.GoalfyMaxUserBanRequest, adminID int) error
|
||||
Unban(id uint) error
|
||||
AddBalance(id uint, req *models.GoalfyMaxUserAddBalanceRequest, operatorID int, operatorEmail string, ipAddress string, userAgent string) error
|
||||
DeductBalance(id uint, req *models.GoalfyMaxUserAddBalanceRequest, operatorID int, operatorEmail string, ipAddress string, userAgent string) error
|
||||
}
|
||||
|
||||
type goalfyMaxUserService struct {
|
||||
storage storage.GoalfyMaxUserStorage
|
||||
messagePushService MessagePushService
|
||||
ssoAdminService SSOAdminService
|
||||
redisClient *redis.Client
|
||||
balanceOperationLogStorage storage.BalanceOperationLogStorage
|
||||
auditLogService AuditLogService
|
||||
logger *utils.Logger
|
||||
}
|
||||
|
||||
func NewGoalfyMaxUserService(s storage.GoalfyMaxUserStorage, messagePushService MessagePushService, ssoAdminService SSOAdminService, redisClient *redis.Client, balanceOperationLogStorage storage.BalanceOperationLogStorage, auditLogService AuditLogService, logger *utils.Logger) GoalfyMaxUserService {
|
||||
return &goalfyMaxUserService{
|
||||
storage: s,
|
||||
messagePushService: messagePushService,
|
||||
ssoAdminService: ssoAdminService,
|
||||
redisClient: redisClient,
|
||||
balanceOperationLogStorage: balanceOperationLogStorage,
|
||||
auditLogService: auditLogService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *goalfyMaxUserService) List(req *models.GoalfyMaxUserListRequest) ([]models.GoalfyMaxUser, int64, error) {
|
||||
users, total, err := s.storage.List(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 如果Redis客户端可用,查询每个用户的余额
|
||||
if s.redisClient != nil {
|
||||
ctx := context.Background()
|
||||
// 余额换算比例:1美元 = 100,000000 (即100000000)
|
||||
const balanceMultiplier = 100000000.0
|
||||
|
||||
for i := range users {
|
||||
// 默认余额为0
|
||||
balanceUSD := 0.0
|
||||
|
||||
// Redis key格式: GW:QU_{用户ID}
|
||||
redisKey := fmt.Sprintf("GW:QU_%d", users[i].UserID)
|
||||
|
||||
// 从Redis获取余额
|
||||
balanceStr, err := s.redisClient.Rdb.Get(ctx, redisKey).Result()
|
||||
if err == nil {
|
||||
// 解析余额值
|
||||
balanceValue, err := strconv.ParseInt(balanceStr, 10, 64)
|
||||
if err == nil {
|
||||
// 转换为美元:除以100000000
|
||||
balanceUSD = float64(balanceValue) / balanceMultiplier
|
||||
}
|
||||
}
|
||||
// 无论是否查询成功,都设置余额(查询失败则为0)
|
||||
users[i].Balance = &balanceUSD
|
||||
}
|
||||
} else {
|
||||
// 如果Redis客户端不可用,设置所有用户余额为0
|
||||
zeroBalance := 0.0
|
||||
for i := range users {
|
||||
users[i].Balance = &zeroBalance
|
||||
}
|
||||
}
|
||||
|
||||
return users, total, nil
|
||||
}
|
||||
|
||||
func (s *goalfyMaxUserService) GetByID(id uint) (*models.GoalfyMaxUser, error) {
|
||||
return s.storage.GetByID(id)
|
||||
}
|
||||
|
||||
func (s *goalfyMaxUserService) Create(req *models.GoalfyMaxUserCreateRequest) (*models.GoalfyMaxUser, error) {
|
||||
// 检查用户名是否已存在
|
||||
_, err := s.storage.GetByUsername(req.Username)
|
||||
if err == nil {
|
||||
return nil, errors.New("用户名已存在")
|
||||
}
|
||||
|
||||
// 检查邮箱是否已存在
|
||||
_, err = s.storage.GetByEmail(req.Email)
|
||||
if err == nil {
|
||||
return nil, errors.New("邮箱已存在")
|
||||
}
|
||||
|
||||
// 1. 先调用SSO创建用户
|
||||
ctx := context.Background()
|
||||
ssoReq := &SSOAdminUserCreateRequest{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Phone: "", // 默认空手机号,可以根据需要修改
|
||||
Password: req.Password, // 使用用户输入的密码
|
||||
}
|
||||
|
||||
ssoUser, err := s.ssoAdminService.CreateUser(ctx, ssoReq)
|
||||
if err != nil {
|
||||
s.logger.Error("create goalfymax user failed", zap.Error(err))
|
||||
return nil, fmt.Errorf("SSO创建用户失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 设置系统角色:默认 custom(而非固定角色ID)
|
||||
if err := s.ssoAdminService.SetSystemRole(ctx, ssoUser.ID, "custom"); err != nil {
|
||||
// 如果设置失败,记录错误但不阻止用户创建
|
||||
if s.logger != nil {
|
||||
s.logger.Error("设置系统角色失败", zap.Int("user_id", ssoUser.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 在本地数据库创建用户记录,使用SSO返回的用户ID
|
||||
// 如果未指定用户等级,默认为 normal
|
||||
userLevelCode := req.UserLevelCode
|
||||
if userLevelCode == "" {
|
||||
userLevelCode = "normal"
|
||||
}
|
||||
|
||||
// 如果未指定版本,默认为 1(用户版)
|
||||
version := req.Version
|
||||
if version == 0 {
|
||||
version = 1
|
||||
}
|
||||
|
||||
user := &models.GoalfyMaxUser{
|
||||
UserID: ssoUser.ID, // 使用SSO返回的用户ID
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Nickname: req.Nickname,
|
||||
Avatar: req.Avatar,
|
||||
UserLevelCode: userLevelCode,
|
||||
Version: version,
|
||||
IsBanned: false,
|
||||
}
|
||||
|
||||
if err := s.storage.Create(user); err != nil {
|
||||
// 如果本地创建失败,需要清理SSO用户(这里简化处理,实际应该调用SSO删除接口)
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("本地用户创建失败,但SSO用户已创建", zap.Int("sso_user_id", ssoUser.ID), zap.Error(err))
|
||||
}
|
||||
return nil, fmt.Errorf("创建用户失败: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *goalfyMaxUserService) Update(id uint, req *models.GoalfyMaxUserUpdateRequest) (*models.GoalfyMaxUser, error) {
|
||||
user, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
if req.Nickname != "" {
|
||||
user.Nickname = req.Nickname
|
||||
}
|
||||
if req.Email != "" {
|
||||
user.Email = req.Email
|
||||
}
|
||||
if req.Avatar != "" {
|
||||
user.Avatar = req.Avatar
|
||||
}
|
||||
if req.UserLevelCode != "" {
|
||||
user.UserLevelCode = req.UserLevelCode
|
||||
}
|
||||
if req.Version != nil {
|
||||
user.Version = *req.Version
|
||||
}
|
||||
if req.GoalfyHubPermission != nil {
|
||||
user.GoalfyHubPermission = *req.GoalfyHubPermission
|
||||
}
|
||||
if err := s.storage.Update(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *goalfyMaxUserService) Delete(id uint) error {
|
||||
_, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
return s.storage.Delete(id)
|
||||
}
|
||||
|
||||
func (s *goalfyMaxUserService) Ban(id uint, req *models.GoalfyMaxUserBanRequest, adminID int) error {
|
||||
user, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
// 执行封禁操作
|
||||
if err := s.storage.SetBanned(id, req.Reason, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 发送封禁通知
|
||||
ctx := context.Background()
|
||||
banMessage := &models.MessagePushRequest{
|
||||
Title: "封禁通知",
|
||||
Content: fmt.Sprintf("您的账户已被封禁。封禁原因:%s。如有疑问,请联系客服。", req.Reason),
|
||||
UserIDs: []int{user.UserID},
|
||||
}
|
||||
|
||||
// 异步发送通知,不阻塞封禁操作
|
||||
go func() {
|
||||
if _, err := s.messagePushService.SendMessage(ctx, banMessage, adminID, "系统管理员"); err != nil {
|
||||
// 记录错误日志,但不影响封禁操作
|
||||
if s.logger != nil {
|
||||
s.logger.Error("发送封禁通知失败", zap.Int("user_id", user.UserID), zap.Int("admin_id", adminID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *goalfyMaxUserService) Unban(id uint) error {
|
||||
user, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
// 执行解封操作
|
||||
if err := s.storage.Unban(id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 发送解封通知
|
||||
ctx := context.Background()
|
||||
unbanMessage := &models.MessagePushRequest{
|
||||
Title: "解封通知",
|
||||
Content: "您的账户已被解封,现在可以正常使用所有功能。感谢您的理解与配合。",
|
||||
UserIDs: []int{user.UserID},
|
||||
}
|
||||
|
||||
// 异步发送通知,不阻塞解封操作
|
||||
go func() {
|
||||
if _, err := s.messagePushService.SendMessage(ctx, unbanMessage, 0, "系统管理员"); err != nil {
|
||||
// 记录错误日志,但不影响解封操作
|
||||
if s.logger != nil {
|
||||
s.logger.Error("发送解封通知失败", zap.Int("user_id", user.UserID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *goalfyMaxUserService) AddBalance(id uint, req *models.GoalfyMaxUserAddBalanceRequest, operatorID int, operatorEmail string, ipAddress string, userAgent string) error {
|
||||
// 检查用户是否存在
|
||||
user, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
// 检查Redis客户端是否可用
|
||||
if s.redisClient == nil {
|
||||
return errors.New("Redis客户端不可用,无法增加余额")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// 余额换算比例:1美元 = 100,000000 (即100000000)
|
||||
const balanceMultiplier = 100000000.0
|
||||
|
||||
// Redis key格式: GW:QU_{用户ID}
|
||||
redisKey := fmt.Sprintf("GW:QU_%d", user.UserID)
|
||||
|
||||
// 获取操作前余额
|
||||
balanceBeforeUSD := 0.0
|
||||
balanceBeforeStr, err := s.redisClient.Rdb.Get(ctx, redisKey).Result()
|
||||
if err == nil {
|
||||
balanceBeforeValue, err := strconv.ParseInt(balanceBeforeStr, 10, 64)
|
||||
if err == nil {
|
||||
balanceBeforeUSD = float64(balanceBeforeValue) / balanceMultiplier
|
||||
}
|
||||
}
|
||||
|
||||
// 将美元金额转换为Redis存储的数值
|
||||
amountToAdd := int64(req.Amount * balanceMultiplier)
|
||||
|
||||
// 使用Redis的INCRBY命令增加余额(如果key不存在,会自动创建并设置为0,然后加上amountToAdd)
|
||||
newBalance, err := s.redisClient.Rdb.IncrBy(ctx, redisKey, amountToAdd).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("增加余额失败: %w", err)
|
||||
}
|
||||
|
||||
// 计算操作后余额
|
||||
balanceAfterUSD := float64(newBalance) / balanceMultiplier
|
||||
|
||||
// 异步记录操作日志(保留原有的余额操作日志)
|
||||
go func() {
|
||||
logEntry := &models.BalanceOperationLog{
|
||||
UserID: user.UserID,
|
||||
OperationType: models.OperationTypeAdd,
|
||||
Amount: req.Amount,
|
||||
BalanceBefore: balanceBeforeUSD,
|
||||
BalanceAfter: balanceAfterUSD,
|
||||
OperatorID: operatorID,
|
||||
OperatorName: operatorEmail,
|
||||
Remark: "", // 可以后续扩展
|
||||
}
|
||||
if err := s.balanceOperationLogStorage.Create(logEntry); err != nil {
|
||||
// 记录错误但不影响主流程
|
||||
if s.logger != nil {
|
||||
s.logger.Error("记录余额操作日志失败",
|
||||
zap.Int("user_id", user.UserID),
|
||||
zap.String("operation_type", models.OperationTypeAdd),
|
||||
zap.Float64("amount", req.Amount),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 异步记录审计日志
|
||||
if s.auditLogService != nil {
|
||||
go func() {
|
||||
operationDetails := models.OperationDetails{
|
||||
"action": "recharge",
|
||||
"amount": req.Amount,
|
||||
"currency": "USD",
|
||||
"before_balance": balanceBeforeUSD,
|
||||
"after_balance": balanceAfterUSD,
|
||||
"remark": "",
|
||||
}
|
||||
|
||||
auditLogReq := &models.CreateAuditLogRequest{
|
||||
OperationType: models.OperationTypeBalanceAdjustment,
|
||||
OperatorID: operatorID,
|
||||
OperatorEmail: operatorEmail,
|
||||
TargetType: models.TargetTypeUser,
|
||||
TargetID: &user.UserID,
|
||||
TargetEmail: user.Email,
|
||||
OperationDetails: operationDetails,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
Status: models.AuditLogStatusSuccess,
|
||||
}
|
||||
|
||||
if err := s.auditLogService.Create(auditLogReq); err != nil {
|
||||
// 记录错误但不影响主流程
|
||||
if s.logger != nil {
|
||||
s.logger.Error("记录审计日志失败",
|
||||
zap.Int("user_id", user.UserID),
|
||||
zap.String("operation_type", models.OperationTypeBalanceAdjustment),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
if s.logger != nil {
|
||||
s.logger.Info("用户余额增加成功",
|
||||
zap.Int("user_id", user.UserID),
|
||||
zap.String("username", user.Username),
|
||||
zap.Float64("amount", req.Amount),
|
||||
zap.Float64("balance_before", balanceBeforeUSD),
|
||||
zap.Float64("balance_after", balanceAfterUSD),
|
||||
zap.Int64("redis_balance_raw", newBalance),
|
||||
zap.Int("operator_id", operatorID),
|
||||
zap.String("operator_email", operatorEmail))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *goalfyMaxUserService) DeductBalance(id uint, req *models.GoalfyMaxUserAddBalanceRequest, operatorID int, operatorEmail string, ipAddress string, userAgent string) error {
|
||||
// 检查用户是否存在
|
||||
user, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
// 检查Redis客户端是否可用
|
||||
if s.redisClient == nil {
|
||||
return errors.New("Redis客户端不可用,无法减少余额")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
// 余额换算比例:1美元 = 100,000000 (即100000000)
|
||||
const balanceMultiplier = 100000000.0
|
||||
|
||||
// Redis key格式: GW:QU_{用户ID}
|
||||
redisKey := fmt.Sprintf("GW:QU_%d", user.UserID)
|
||||
|
||||
// 将美元金额转换为Redis存储的数值
|
||||
amountToDeduct := int64(req.Amount * balanceMultiplier)
|
||||
|
||||
// 先获取当前余额,检查是否足够
|
||||
currentBalance, err := s.redisClient.Rdb.Get(ctx, redisKey).Int64()
|
||||
if err != nil {
|
||||
// 如果key不存在,表示余额为0,不能减少
|
||||
if err == goredis.Nil {
|
||||
return errors.New("余额不足,无法减少")
|
||||
}
|
||||
return fmt.Errorf("查询余额失败: %w", err)
|
||||
}
|
||||
|
||||
// 计算操作前余额
|
||||
balanceBeforeUSD := float64(currentBalance) / balanceMultiplier
|
||||
|
||||
// 检查余额是否足够
|
||||
if currentBalance < amountToDeduct {
|
||||
return errors.New("余额不足,无法减少")
|
||||
}
|
||||
|
||||
// 使用Redis的DECRBY命令减少余额
|
||||
newBalance, err := s.redisClient.Rdb.DecrBy(ctx, redisKey, amountToDeduct).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("减少余额失败: %w", err)
|
||||
}
|
||||
|
||||
// 计算操作后余额
|
||||
balanceAfterUSD := float64(newBalance) / balanceMultiplier
|
||||
|
||||
// 异步记录操作日志(保留原有的余额操作日志)
|
||||
go func() {
|
||||
logEntry := &models.BalanceOperationLog{
|
||||
UserID: user.UserID,
|
||||
OperationType: models.OperationTypeDeduct,
|
||||
Amount: req.Amount,
|
||||
BalanceBefore: balanceBeforeUSD,
|
||||
BalanceAfter: balanceAfterUSD,
|
||||
OperatorID: operatorID,
|
||||
OperatorName: operatorEmail,
|
||||
Remark: "", // 可以后续扩展
|
||||
}
|
||||
if err := s.balanceOperationLogStorage.Create(logEntry); err != nil {
|
||||
// 记录错误但不影响主流程
|
||||
if s.logger != nil {
|
||||
s.logger.Error("记录余额操作日志失败",
|
||||
zap.Int("user_id", user.UserID),
|
||||
zap.String("operation_type", models.OperationTypeDeduct),
|
||||
zap.Float64("amount", req.Amount),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 异步记录审计日志
|
||||
if s.auditLogService != nil {
|
||||
go func() {
|
||||
operationDetails := models.OperationDetails{
|
||||
"action": "deduct",
|
||||
"amount": req.Amount,
|
||||
"currency": "USD",
|
||||
"before_balance": balanceBeforeUSD,
|
||||
"after_balance": balanceAfterUSD,
|
||||
"remark": "",
|
||||
}
|
||||
|
||||
auditLogReq := &models.CreateAuditLogRequest{
|
||||
OperationType: models.OperationTypeBalanceAdjustment,
|
||||
OperatorID: operatorID,
|
||||
OperatorEmail: operatorEmail,
|
||||
TargetType: models.TargetTypeUser,
|
||||
TargetID: &user.UserID,
|
||||
TargetEmail: user.Email,
|
||||
OperationDetails: operationDetails,
|
||||
IPAddress: ipAddress,
|
||||
UserAgent: userAgent,
|
||||
Status: models.AuditLogStatusSuccess,
|
||||
}
|
||||
|
||||
if err := s.auditLogService.Create(auditLogReq); err != nil {
|
||||
// 记录错误但不影响主流程
|
||||
if s.logger != nil {
|
||||
s.logger.Error("记录审计日志失败",
|
||||
zap.Int("user_id", user.UserID),
|
||||
zap.String("operation_type", models.OperationTypeBalanceAdjustment),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
if s.logger != nil {
|
||||
s.logger.Info("用户余额减少成功",
|
||||
zap.Int("user_id", user.UserID),
|
||||
zap.String("username", user.Username),
|
||||
zap.Float64("amount", req.Amount),
|
||||
zap.Float64("balance_before", balanceBeforeUSD),
|
||||
zap.Float64("balance_after", balanceAfterUSD),
|
||||
zap.Int64("redis_balance_raw", newBalance),
|
||||
zap.Int("operator_id", operatorID),
|
||||
zap.String("operator_email", operatorEmail))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
310
internal/services/invite_code_application_service.go
Normal file
310
internal/services/invite_code_application_service.go
Normal file
@@ -0,0 +1,310 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"goalfymax-admin/internal/models"
|
||||
)
|
||||
|
||||
type InviteCodeApplicationService struct {
|
||||
db *gorm.DB
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
// NewInviteCodeApplicationService 创建邀请码申请服务
|
||||
func NewInviteCodeApplicationService(db *gorm.DB) *InviteCodeApplicationService {
|
||||
return &InviteCodeApplicationService{
|
||||
db: db,
|
||||
emailService: NewEmailService(),
|
||||
}
|
||||
}
|
||||
|
||||
// SubmitApplication 提交邀请码申请(官网使用)
|
||||
func (s *InviteCodeApplicationService) SubmitApplication(req *models.InviteCodeApplicationCreateRequest) (*models.InviteCodeApplication, error) {
|
||||
// 检查是否已经有待处理或已通过的申请
|
||||
var existingApp models.InviteCodeApplication
|
||||
err := s.db.Where("email = ? AND status IN (?, ?)",
|
||||
req.Email,
|
||||
models.ApplicationStatusPending,
|
||||
models.ApplicationStatusApproved,
|
||||
).First(&existingApp).Error
|
||||
|
||||
if err == nil {
|
||||
// 如果找到了记录,说明已经有申请
|
||||
if existingApp.Status == models.ApplicationStatusPending {
|
||||
return nil, errors.New("您已经提交过申请,请等待审核")
|
||||
}
|
||||
if existingApp.Status == models.ApplicationStatusApproved {
|
||||
return nil, errors.New("您的申请已通过,请检查邮箱")
|
||||
}
|
||||
}
|
||||
|
||||
// 设置默认语言
|
||||
language := req.Language
|
||||
if language == "" {
|
||||
language = "zh"
|
||||
}
|
||||
|
||||
// 创建新的申请
|
||||
application := &models.InviteCodeApplication{
|
||||
Email: req.Email,
|
||||
Reason: req.Reason,
|
||||
Language: language,
|
||||
Status: models.ApplicationStatusPending,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.db.Create(application).Error; err != nil {
|
||||
return nil, fmt.Errorf("创建申请失败: %w", err)
|
||||
}
|
||||
|
||||
return application, nil
|
||||
}
|
||||
|
||||
// GetApplicationList 获取申请列表(后台管理使用)
|
||||
func (s *InviteCodeApplicationService) GetApplicationList(req *models.InviteCodeApplicationListRequest) (*models.InviteCodeApplicationListResponse, error) {
|
||||
var applications []models.InviteCodeApplication
|
||||
var total int64
|
||||
|
||||
query := s.db.Model(&models.InviteCodeApplication{})
|
||||
|
||||
// 添加过滤条件
|
||||
if req.Email != "" {
|
||||
query = query.Where("email LIKE ?", "%"+req.Email+"%")
|
||||
}
|
||||
if req.Status != "" {
|
||||
query = query.Where("status = ?", req.Status)
|
||||
}
|
||||
if req.StartTime != "" {
|
||||
query = query.Where("created_at >= ?", req.StartTime)
|
||||
}
|
||||
if req.EndTime != "" {
|
||||
endTime, _ := time.Parse("2006-01-02", req.EndTime)
|
||||
query = query.Where("created_at < ?", endTime.AddDate(0, 0, 1))
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
query.Count(&total)
|
||||
|
||||
// 分页查询
|
||||
offset := (req.Page - 1) * req.Size
|
||||
err := query.
|
||||
Preload("InviteCode").
|
||||
Order("created_at DESC").
|
||||
Limit(req.Size).
|
||||
Offset(offset).
|
||||
Find(&applications).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询申请列表失败: %w", err)
|
||||
}
|
||||
|
||||
return &models.InviteCodeApplicationListResponse{
|
||||
List: applications,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetStatistics 获取申请统计
|
||||
func (s *InviteCodeApplicationService) GetStatistics() (*models.InviteCodeApplicationStatistics, error) {
|
||||
var stats models.InviteCodeApplicationStatistics
|
||||
|
||||
// 待处理数量
|
||||
var pendingCount int64
|
||||
s.db.Model(&models.InviteCodeApplication{}).
|
||||
Where("status = ?", models.ApplicationStatusPending).
|
||||
Count(&pendingCount)
|
||||
stats.TotalPending = int(pendingCount)
|
||||
|
||||
// 已通过数量
|
||||
var approvedCount int64
|
||||
s.db.Model(&models.InviteCodeApplication{}).
|
||||
Where("status = ?", models.ApplicationStatusApproved).
|
||||
Count(&approvedCount)
|
||||
stats.TotalApproved = int(approvedCount)
|
||||
|
||||
// 已拒绝数量
|
||||
var rejectedCount int64
|
||||
s.db.Model(&models.InviteCodeApplication{}).
|
||||
Where("status = ?", models.ApplicationStatusRejected).
|
||||
Count(&rejectedCount)
|
||||
stats.TotalRejected = int(rejectedCount)
|
||||
|
||||
// 今日申请数量
|
||||
var todayCount int64
|
||||
todayStart := time.Now().Truncate(24 * time.Hour)
|
||||
s.db.Model(&models.InviteCodeApplication{}).
|
||||
Where("created_at >= ?", todayStart).
|
||||
Count(&todayCount)
|
||||
stats.TodayApplied = int(todayCount)
|
||||
|
||||
return &stats, nil
|
||||
}
|
||||
|
||||
// ApproveApplication 审批通过申请
|
||||
func (s *InviteCodeApplicationService) ApproveApplication(req *models.InviteCodeApplicationApproveRequest, approvedBy string) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 获取申请信息
|
||||
var application models.InviteCodeApplication
|
||||
if err := tx.First(&application, req.ApplicationID).Error; err != nil {
|
||||
return fmt.Errorf("申请不存在: %w", err)
|
||||
}
|
||||
|
||||
if application.Status != models.ApplicationStatusPending {
|
||||
return errors.New("申请状态不是待处理,无法审批")
|
||||
}
|
||||
|
||||
// 设置有效期,默认3天(72小时)
|
||||
validDays := req.ValidDays
|
||||
if validDays <= 0 {
|
||||
validDays = 3
|
||||
}
|
||||
expiresAt := time.Now().AddDate(0, 0, validDays)
|
||||
|
||||
// 创建邀请码
|
||||
inviteCode := &models.InviteCode{
|
||||
Code: s.generateInviteCode(),
|
||||
IsUsed: false,
|
||||
ClientID: "xRpT9mgNpt2YvoY9z4FToA", // 默认为正式版客户端
|
||||
ExpiresAt: &expiresAt,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := tx.Create(inviteCode).Error; err != nil {
|
||||
return fmt.Errorf("创建邀请码失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新申请状态
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"status": models.ApplicationStatusApproved,
|
||||
"invite_code_id": inviteCode.ID,
|
||||
"approved_at": now,
|
||||
"approved_by": approvedBy,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
if err := tx.Model(&application).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("更新申请状态失败: %w", err)
|
||||
}
|
||||
|
||||
// 发送邮件(异步,不影响事务)
|
||||
go func() {
|
||||
// 获取语言设置,默认为中文
|
||||
lang := application.Language
|
||||
if lang == "" {
|
||||
lang = "zh"
|
||||
}
|
||||
if err := s.emailService.SendInviteCodeApprovalEmail(application.Email, inviteCode.Code, lang, &expiresAt); err != nil {
|
||||
// 记录邮件发送失败,但不回滚事务
|
||||
fmt.Printf("发送审批通过邮件失败: %v\n", err)
|
||||
} else {
|
||||
// 更新邮件发送时间
|
||||
emailSentAt := time.Now()
|
||||
tx.Model(&application).Update("email_sent_at", emailSentAt)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RejectApplication 审批拒绝申请
|
||||
func (s *InviteCodeApplicationService) RejectApplication(req *models.InviteCodeApplicationRejectRequest, approvedBy string) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 获取申请信息
|
||||
var application models.InviteCodeApplication
|
||||
if err := tx.First(&application, req.ApplicationID).Error; err != nil {
|
||||
return fmt.Errorf("申请不存在: %w", err)
|
||||
}
|
||||
|
||||
if application.Status != models.ApplicationStatusPending {
|
||||
return errors.New("申请状态不是待处理,无法审批")
|
||||
}
|
||||
|
||||
// 更新申请状态
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"status": models.ApplicationStatusRejected,
|
||||
"reject_reason": req.RejectReason,
|
||||
"approved_at": now,
|
||||
"approved_by": approvedBy,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
if err := tx.Model(&application).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("更新申请状态失败: %w", err)
|
||||
}
|
||||
|
||||
// 拒绝操作不发送邮件通知
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// BatchApproveApplications 批量审批通过
|
||||
func (s *InviteCodeApplicationService) BatchApproveApplications(req *models.InviteCodeApplicationBatchApproveRequest, approvedBy string) error {
|
||||
for _, appID := range req.ApplicationIDs {
|
||||
approveReq := &models.InviteCodeApplicationApproveRequest{
|
||||
ApplicationID: appID,
|
||||
ValidDays: req.ValidDays,
|
||||
}
|
||||
if err := s.ApproveApplication(approveReq, approvedBy); err != nil {
|
||||
// 记录错误但继续处理其他申请
|
||||
fmt.Printf("审批申请 %d 失败: %v\n", appID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchRejectApplications 批量审批拒绝
|
||||
func (s *InviteCodeApplicationService) BatchRejectApplications(req *models.InviteCodeApplicationBatchRejectRequest, approvedBy string) error {
|
||||
for _, appID := range req.ApplicationIDs {
|
||||
rejectReq := &models.InviteCodeApplicationRejectRequest{
|
||||
ApplicationID: appID,
|
||||
RejectReason: req.RejectReason,
|
||||
}
|
||||
if err := s.RejectApplication(rejectReq, approvedBy); err != nil {
|
||||
// 记录错误但继续处理其他申请
|
||||
fmt.Printf("拒绝申请 %d 失败: %v\n", appID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateInviteCode 生成唯一的邀请码,格式:GFY-XXXXXXXX-XXXX
|
||||
func (s *InviteCodeApplicationService) generateInviteCode() string {
|
||||
// 生成UUID并转换为大写
|
||||
uuidStr := uuid.New().String()
|
||||
|
||||
// 移除UUID中的连字符并转为大写
|
||||
cleaned := ""
|
||||
for _, c := range uuidStr {
|
||||
if c != '-' {
|
||||
cleaned += string(c)
|
||||
}
|
||||
}
|
||||
cleaned = strings.ToUpper(cleaned)
|
||||
|
||||
// 截取需要的部分:GFY-8位-4位
|
||||
// 格式:GFY-XXXXXXXX-XXXX
|
||||
part1 := cleaned[0:8] // 8位
|
||||
part2 := cleaned[8:12] // 4位
|
||||
|
||||
return fmt.Sprintf("GFY-%s-%s", part1, part2)
|
||||
}
|
||||
|
||||
// GetPendingApplicationsCount 获取待处理申请数量
|
||||
func (s *InviteCodeApplicationService) GetPendingApplicationsCount() (int64, error) {
|
||||
var count int64
|
||||
err := s.db.Model(&models.InviteCodeApplication{}).
|
||||
Where("status = ?", models.ApplicationStatusPending).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
291
internal/services/invite_code_service.go
Normal file
291
internal/services/invite_code_service.go
Normal file
@@ -0,0 +1,291 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
)
|
||||
|
||||
type InviteCodeService interface {
|
||||
Create(req *models.InviteCodeCreateRequest) ([]*models.InviteCode, error)
|
||||
GetByID(id uint) (*models.InviteCode, error)
|
||||
GetByCode(code string) (*models.InviteCode, error)
|
||||
List(req *models.InviteCodeListRequest) (*models.InviteCodeListResponse, error)
|
||||
Update(id uint, req *models.InviteCodeUpdateRequest) (*models.InviteCode, error)
|
||||
Delete(id uint) error
|
||||
GetStatistics() (*models.InviteCodeStatistics, error)
|
||||
MarkAsUsed(code string) error
|
||||
ValidateInviteCode(code string) error
|
||||
}
|
||||
|
||||
type inviteCodeService struct {
|
||||
storage storage.InviteCodeStorage
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
func NewInviteCodeService(storage storage.InviteCodeStorage) InviteCodeService {
|
||||
return &inviteCodeService{
|
||||
storage: storage,
|
||||
emailService: NewEmailService(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *inviteCodeService) Create(req *models.InviteCodeCreateRequest) ([]*models.InviteCode, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("请求参数不能为空")
|
||||
}
|
||||
|
||||
// 设置过期时间
|
||||
var expiresAt *time.Time
|
||||
if req.ExpiresAt != nil {
|
||||
expiresAt = req.ExpiresAt
|
||||
} else {
|
||||
// 默认30天后过期
|
||||
defaultExpiry := time.Now().AddDate(0, 0, 30)
|
||||
expiresAt = &defaultExpiry
|
||||
}
|
||||
|
||||
// 设置用户等级ID,如果未提供则默认为1
|
||||
var userLevelID *uint
|
||||
if req.UserLevelID != nil {
|
||||
userLevelID = req.UserLevelID
|
||||
} else {
|
||||
defaultUserLevelID := uint(1)
|
||||
userLevelID = &defaultUserLevelID
|
||||
}
|
||||
|
||||
// 如果提供了邮箱列表,为每个邮箱创建一个邀请码
|
||||
if len(req.Emails) > 0 {
|
||||
// 对邮箱列表进行去重处理
|
||||
emailMap := make(map[string]bool)
|
||||
uniqueEmails := make([]string, 0, len(req.Emails))
|
||||
for _, email := range req.Emails {
|
||||
// 去除空格并转换为小写进行去重
|
||||
email = strings.TrimSpace(strings.ToLower(email))
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
// 如果邮箱已存在,跳过
|
||||
if emailMap[email] {
|
||||
continue
|
||||
}
|
||||
emailMap[email] = true
|
||||
uniqueEmails = append(uniqueEmails, email)
|
||||
}
|
||||
|
||||
var inviteCodes []*models.InviteCode
|
||||
for _, email := range uniqueEmails {
|
||||
|
||||
// 生成唯一邀请码
|
||||
var code string
|
||||
for {
|
||||
code = s.generateCode()
|
||||
existingCode, err := s.storage.GetByCode(code)
|
||||
if err != nil || existingCode == nil {
|
||||
break // 邀请码不存在,可以使用
|
||||
}
|
||||
}
|
||||
|
||||
inviteCode := &models.InviteCode{
|
||||
Code: code,
|
||||
ClientID: req.ClientID,
|
||||
Email: email,
|
||||
UserLevelID: userLevelID,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.storage.Create(inviteCode); err != nil {
|
||||
return nil, fmt.Errorf("创建邀请码失败: %w", err)
|
||||
}
|
||||
|
||||
// 发送邀请邮件(如果提供了邮箱)
|
||||
// 使用 goroutine 异步发送,避免阻塞
|
||||
if email != "" && s.emailService != nil {
|
||||
go func(emailAddr, code string) {
|
||||
// 使用默认语言 "zh" 发送邮件
|
||||
if err := s.emailService.SendInviteCodeApprovalEmail(emailAddr, code, "zh", expiresAt); err != nil {
|
||||
// 记录错误但不影响创建流程,邮件发送失败不影响邀请码创建
|
||||
fmt.Printf("[InviteCodeService] 发送邀请邮件失败: %v (邀请码: %s, 邮箱: %s)\n", err, code, emailAddr)
|
||||
} else {
|
||||
fmt.Printf("[InviteCodeService] 邀请邮件发送成功 (邀请码: %s, 邮箱: %s)\n", code, emailAddr)
|
||||
}
|
||||
}(email, inviteCode.Code)
|
||||
}
|
||||
|
||||
inviteCodes = append(inviteCodes, inviteCode)
|
||||
}
|
||||
|
||||
if len(inviteCodes) == 0 {
|
||||
return nil, fmt.Errorf("没有有效的邮箱地址")
|
||||
}
|
||||
|
||||
return inviteCodes, nil
|
||||
}
|
||||
|
||||
// 如果没有提供邮箱,只创建一个邀请码(向后兼容)
|
||||
var code string
|
||||
for {
|
||||
code = s.generateCode()
|
||||
existingCode, err := s.storage.GetByCode(code)
|
||||
if err != nil || existingCode == nil {
|
||||
break // 邀请码不存在,可以使用
|
||||
}
|
||||
}
|
||||
|
||||
inviteCode := &models.InviteCode{
|
||||
Code: code,
|
||||
ClientID: req.ClientID,
|
||||
Email: "",
|
||||
UserLevelID: userLevelID,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := s.storage.Create(inviteCode); err != nil {
|
||||
return nil, fmt.Errorf("创建邀请码失败: %w", err)
|
||||
}
|
||||
|
||||
return []*models.InviteCode{inviteCode}, nil
|
||||
}
|
||||
|
||||
func (s *inviteCodeService) GetByID(id uint) (*models.InviteCode, error) {
|
||||
return s.storage.GetByID(id)
|
||||
}
|
||||
|
||||
func (s *inviteCodeService) GetByCode(code string) (*models.InviteCode, error) {
|
||||
return s.storage.GetByCode(code)
|
||||
}
|
||||
|
||||
func (s *inviteCodeService) List(req *models.InviteCodeListRequest) (*models.InviteCodeListResponse, error) {
|
||||
inviteCodes, total, err := s.storage.List(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取邀请码列表失败: %w", err)
|
||||
}
|
||||
|
||||
return &models.InviteCodeListResponse{
|
||||
List: inviteCodes,
|
||||
Total: total,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *inviteCodeService) Update(id uint, req *models.InviteCodeUpdateRequest) (*models.InviteCode, error) {
|
||||
// 获取现有邀请码
|
||||
inviteCode, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("邀请码不存在")
|
||||
}
|
||||
|
||||
// 检查是否已被使用
|
||||
if inviteCode.IsUsed {
|
||||
return nil, fmt.Errorf("已使用的邀请码无法修改")
|
||||
}
|
||||
|
||||
// 更新客户端ID
|
||||
if req != nil && req.ClientID != "" {
|
||||
inviteCode.ClientID = req.ClientID
|
||||
}
|
||||
|
||||
// 更新邮箱
|
||||
if req != nil && req.Email != "" {
|
||||
inviteCode.Email = req.Email
|
||||
}
|
||||
|
||||
// 更新用户等级ID
|
||||
if req != nil && req.UserLevelID != nil {
|
||||
inviteCode.UserLevelID = req.UserLevelID
|
||||
}
|
||||
|
||||
// 更新过期时间
|
||||
if req != nil && req.ExpiresAt != nil {
|
||||
inviteCode.ExpiresAt = req.ExpiresAt
|
||||
}
|
||||
|
||||
// 保存更新
|
||||
if err := s.storage.Update(inviteCode); err != nil {
|
||||
return nil, fmt.Errorf("更新邀请码失败: %w", err)
|
||||
}
|
||||
|
||||
return inviteCode, nil
|
||||
}
|
||||
|
||||
func (s *inviteCodeService) Delete(id uint) error {
|
||||
_, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("邀请码不存在")
|
||||
}
|
||||
|
||||
if err := s.storage.Delete(id); err != nil {
|
||||
return fmt.Errorf("删除邀请码失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *inviteCodeService) GetStatistics() (*models.InviteCodeStatistics, error) {
|
||||
return s.storage.GetStatistics()
|
||||
}
|
||||
|
||||
func (s *inviteCodeService) MarkAsUsed(code string) error {
|
||||
inviteCode, err := s.storage.GetByCode(code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("邀请码不存在")
|
||||
}
|
||||
|
||||
if inviteCode.IsUsed {
|
||||
return fmt.Errorf("邀请码已被使用")
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if s.storage.IsExpired(inviteCode) {
|
||||
return fmt.Errorf("邀请码已过期")
|
||||
}
|
||||
|
||||
inviteCode.IsUsed = true
|
||||
if err := s.storage.Update(inviteCode); err != nil {
|
||||
return fmt.Errorf("标记邀请码为已使用失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateInviteCode 验证邀请码是否有效(未使用且未过期)
|
||||
func (s *inviteCodeService) ValidateInviteCode(code string) error {
|
||||
inviteCode, err := s.storage.GetByCode(code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("邀请码不存在")
|
||||
}
|
||||
|
||||
if inviteCode.IsUsed {
|
||||
return fmt.Errorf("邀请码已被使用")
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if s.storage.IsExpired(inviteCode) {
|
||||
return fmt.Errorf("邀请码已过期")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *inviteCodeService) generateCode() string {
|
||||
chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
|
||||
// 生成8位随机字符
|
||||
part1 := make([]byte, 8)
|
||||
for i := range part1 {
|
||||
part1[i] = chars[rand.Intn(len(chars))]
|
||||
}
|
||||
|
||||
// 生成4位随机字符
|
||||
part2 := make([]byte, 4)
|
||||
for i := range part2 {
|
||||
part2[i] = chars[rand.Intn(len(chars))]
|
||||
}
|
||||
|
||||
return fmt.Sprintf("GFY-%s-%s", string(part1), string(part2))
|
||||
}
|
||||
85
internal/services/log_service.go
Normal file
85
internal/services/log_service.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
"goalfymax-admin/pkg/utils"
|
||||
)
|
||||
|
||||
// LogService 日志服务接口
|
||||
type LogService interface {
|
||||
CreateLoginLog(log *models.LoginLog) error
|
||||
CreateOperationLog(log *models.OperationLog) error
|
||||
GetLoginLogs(req *models.LoginLogListRequest) ([]models.LoginLog, int64, error)
|
||||
GetOperationLogs(req *models.OperationLogListRequest) ([]models.OperationLog, int64, error)
|
||||
DeleteLoginLogs(beforeDate string) error
|
||||
DeleteOperationLogs(beforeDate string) error
|
||||
}
|
||||
|
||||
type logService struct {
|
||||
logStorage storage.LogStorage
|
||||
logger *utils.Logger
|
||||
}
|
||||
|
||||
// NewLogService 创建日志服务实例
|
||||
func NewLogService(logStorage storage.LogStorage, logger *utils.Logger) LogService {
|
||||
return &logService{
|
||||
logStorage: logStorage,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateLoginLog 创建登录日志
|
||||
func (s *logService) CreateLoginLog(log *models.LoginLog) error {
|
||||
err := s.logStorage.CreateLoginLog(log)
|
||||
if err != nil {
|
||||
s.logger.Error("创建登录日志失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateOperationLog 创建操作日志
|
||||
func (s *logService) CreateOperationLog(log *models.OperationLog) error {
|
||||
err := s.logStorage.CreateOperationLog(log)
|
||||
if err != nil {
|
||||
s.logger.Error("创建操作日志失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLoginLogs 获取登录日志列表
|
||||
func (s *logService) GetLoginLogs(req *models.LoginLogListRequest) ([]models.LoginLog, int64, error) {
|
||||
return s.logStorage.GetLoginLogs(req)
|
||||
}
|
||||
|
||||
// GetOperationLogs 获取操作日志列表
|
||||
func (s *logService) GetOperationLogs(req *models.OperationLogListRequest) ([]models.OperationLog, int64, error) {
|
||||
return s.logStorage.GetOperationLogs(req)
|
||||
}
|
||||
|
||||
// DeleteLoginLogs 删除指定日期之前的登录日志
|
||||
func (s *logService) DeleteLoginLogs(beforeDate string) error {
|
||||
err := s.logStorage.DeleteLoginLogs(beforeDate)
|
||||
if err != nil {
|
||||
s.logger.Error("删除登录日志失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("登录日志删除成功", zap.String("beforeDate", beforeDate))
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOperationLogs 删除指定日期之前的操作日志
|
||||
func (s *logService) DeleteOperationLogs(beforeDate string) error {
|
||||
err := s.logStorage.DeleteOperationLogs(beforeDate)
|
||||
if err != nil {
|
||||
s.logger.Error("删除操作日志失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("操作日志删除成功", zap.String("beforeDate", beforeDate))
|
||||
return nil
|
||||
}
|
||||
393
internal/services/message_push_service.go
Normal file
393
internal/services/message_push_service.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"goalfymax-admin/internal/config"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
"html"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// MessagePushService 消息推送服务接口
|
||||
type MessagePushService interface {
|
||||
SendMessage(ctx context.Context, req *models.MessagePushRequest, senderID int, senderName string) (*models.MessagePushResponse, error)
|
||||
GetPushLogs(ctx context.Context, req *models.MessagePushListRequest) (*models.MessagePushListResponse, error)
|
||||
GetPushLogByID(ctx context.Context, id int64) (*models.MessagePushLog, error)
|
||||
SearchUsers(ctx context.Context, req *models.UserSearchRequest) (*models.UserSearchResponse, error)
|
||||
}
|
||||
|
||||
type messagePushService struct {
|
||||
storage storage.MessagePushStorage
|
||||
goalfymaxClient *http.Client
|
||||
goalfymaxURL string
|
||||
retryCount int
|
||||
retryInterval time.Duration
|
||||
emailService *EmailService
|
||||
goalfyMaxUserStorage storage.GoalfyMaxUserStorage
|
||||
}
|
||||
|
||||
// NewMessagePushService 创建消息推送服务实例
|
||||
func NewMessagePushService() MessagePushService {
|
||||
cfg := config.GetConfig()
|
||||
return &messagePushService{
|
||||
storage: storage.NewMessagePushStorage(),
|
||||
goalfymaxClient: &http.Client{
|
||||
Timeout: time.Duration(cfg.MessagePush.Timeout) * time.Second,
|
||||
},
|
||||
goalfymaxURL: cfg.MessagePush.GoalfyMaxBaseURL,
|
||||
retryCount: cfg.MessagePush.RetryCount,
|
||||
retryInterval: time.Duration(cfg.MessagePush.RetryInterval) * time.Millisecond,
|
||||
emailService: NewEmailService(),
|
||||
goalfyMaxUserStorage: storage.NewGoalfyMaxUserStorage(),
|
||||
}
|
||||
}
|
||||
|
||||
// SendMessage 发送消息
|
||||
func (s *messagePushService) SendMessage(ctx context.Context, req *models.MessagePushRequest, senderID int, senderName string) (*models.MessagePushResponse, error) {
|
||||
// 参数校验
|
||||
if len(req.UserIDs) == 0 {
|
||||
return nil, fmt.Errorf("目标用户不能为空")
|
||||
}
|
||||
if len(req.Title) == 0 {
|
||||
return nil, fmt.Errorf("消息标题不能为空")
|
||||
}
|
||||
titleRuneCount := utf8.RuneCountInString(req.Title)
|
||||
if titleRuneCount > 100 {
|
||||
return nil, fmt.Errorf("消息标题不能超过100字符")
|
||||
}
|
||||
if len(req.Content) == 0 {
|
||||
return nil, fmt.Errorf("消息内容不能为空")
|
||||
}
|
||||
contentRuneCount := utf8.RuneCountInString(req.Content)
|
||||
if contentRuneCount > 2000 {
|
||||
return nil, fmt.Errorf("消息内容不能超过2000字符")
|
||||
}
|
||||
|
||||
// 序列化目标用户
|
||||
targetUsersJSON, err := storage.SerializeTargetUsers(req.UserIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化目标用户失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建推送记录
|
||||
log := &models.MessagePushLog{
|
||||
Title: req.Title,
|
||||
Content: req.Content,
|
||||
TargetUsers: targetUsersJSON,
|
||||
SenderID: senderID,
|
||||
SenderName: senderName,
|
||||
Status: 0, // 待发送
|
||||
}
|
||||
|
||||
if err := s.storage.Create(ctx, log); err != nil {
|
||||
return nil, fmt.Errorf("创建推送记录失败: %w", err)
|
||||
}
|
||||
|
||||
// 异步发送消息
|
||||
go s.sendToGoalfyMaxAsync(context.Background(), log.ID, req.Title, req.Content, req.UserIDs)
|
||||
|
||||
return &models.MessagePushResponse{
|
||||
LogID: log.ID,
|
||||
SuccessCount: 0,
|
||||
FailCount: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sendToGoalfyMaxAsync 异步发送到GoalfyMax
|
||||
func (s *messagePushService) sendToGoalfyMaxAsync(ctx context.Context, logID int64, title string, content string, userIDs []int) {
|
||||
// 更新状态为发送中
|
||||
s.storage.UpdateStatus(ctx, logID, 1, 0, 0, "")
|
||||
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
var errorMessage string
|
||||
|
||||
// 调用GoalfyMax接口
|
||||
err := s.callGoalfyMaxAPI(ctx, title, content, userIDs)
|
||||
if err != nil {
|
||||
failCount = len(userIDs)
|
||||
errorMessage = err.Error()
|
||||
s.storage.UpdateStatus(ctx, logID, 3, successCount, failCount, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// 发送成功,同时发送邮件
|
||||
s.sendEmails(ctx, title, content, userIDs)
|
||||
|
||||
// 发送成功
|
||||
successCount = len(userIDs)
|
||||
s.storage.UpdateStatus(ctx, logID, 2, successCount, failCount, "")
|
||||
}
|
||||
|
||||
// sendEmails 发送邮件给用户
|
||||
func (s *messagePushService) sendEmails(ctx context.Context, title string, content string, userIDs []int) {
|
||||
// 生成邮件HTML内容
|
||||
htmlContent := s.generateMessageEmailHTML(title, content)
|
||||
|
||||
// 循环发送邮件给每个用户
|
||||
for _, userID := range userIDs {
|
||||
user, err := s.goalfyMaxUserStorage.GetByUserID(userID)
|
||||
if err != nil {
|
||||
// 如果获取用户失败,跳过该用户
|
||||
continue
|
||||
}
|
||||
if user.Email != "" {
|
||||
// 发送邮件
|
||||
if err := s.emailService.sendEmail(user.Email, title, htmlContent); err != nil {
|
||||
// 记录错误但不中断其他邮件的发送
|
||||
continue
|
||||
}
|
||||
// 避免发送过快,每次发送后稍作延迟
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generateMessageEmailHTML 生成消息通知邮件的HTML内容
|
||||
func (s *messagePushService) generateMessageEmailHTML(title, content string) string {
|
||||
// 转义HTML特殊字符,然后将换行符转换为<br>
|
||||
escapedContent := html.EscapeString(content)
|
||||
// 将换行符转换为HTML换行
|
||||
contentHTML := fmt.Sprintf(`<div style="white-space: pre-wrap;">%s</div>`, escapedContent)
|
||||
|
||||
tmplStr := `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1a1a1a;
|
||||
background-color: #f5f5f5;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.email-wrapper {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.header {
|
||||
background-color: #000000;
|
||||
padding: 32px 40px;
|
||||
text-align: center;
|
||||
}
|
||||
.header-logo {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.content {
|
||||
padding: 40px;
|
||||
}
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.message-content {
|
||||
font-size: 15px;
|
||||
color: #4a4a4a;
|
||||
line-height: 1.7;
|
||||
background-color: #fafafa;
|
||||
padding: 20px 24px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.footer {
|
||||
background-color: #fafafa;
|
||||
padding: 32px 40px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
.footer-brand {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.footer-contact {
|
||||
font-size: 13px;
|
||||
color: #666666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@media only screen and (max-width: 600px) {
|
||||
.content {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
.header {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-wrapper">
|
||||
<div class="header">
|
||||
<div class="header-logo">GoalfyAI</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="title">{{.Title}}</div>
|
||||
<div class="message-content">{{.Content}}</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="footer-brand">GoalfyAI 团队</div>
|
||||
<div class="footer-contact">
|
||||
© 2025 GoalfyAI. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
tmpl, _ := template.New("message").Parse(tmplStr)
|
||||
var buf bytes.Buffer
|
||||
tmpl.Execute(&buf, map[string]interface{}{
|
||||
"Title": html.EscapeString(title),
|
||||
"Content": template.HTML(contentHTML),
|
||||
})
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// callGoalfyMaxAPI 调用GoalfyMax API
|
||||
func (s *messagePushService) callGoalfyMaxAPI(ctx context.Context, title string, content string, userIDs []int) error {
|
||||
// 构建请求体
|
||||
reqBody := map[string]interface{}{
|
||||
"title": title,
|
||||
"content": content,
|
||||
"user_ids": userIDs,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化请求失败: %w", err)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for i := 0; i < s.retryCount; i++ {
|
||||
// 创建HTTP请求
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", s.goalfymaxURL+"/api/notifications/send", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("创建请求失败: %w", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 设置固定Token
|
||||
req.Header.Set("Authorization", "Bearer admin-message-push")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 发送请求
|
||||
resp, err := s.goalfymaxClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("发送请求失败 (尝试 %d/%d): %w", i+1, s.retryCount, err)
|
||||
if i < s.retryCount-1 {
|
||||
time.Sleep(s.retryInterval)
|
||||
}
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
lastErr = fmt.Errorf("读取响应失败: %w", err)
|
||||
if i < s.retryCount-1 {
|
||||
time.Sleep(s.retryInterval)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
lastErr = fmt.Errorf("GoalfyMax接口返回错误: %d, 响应: %s", resp.StatusCode, string(body))
|
||||
if i < s.retryCount-1 {
|
||||
time.Sleep(s.retryInterval)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// 成功,返回nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// 所有重试都失败
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// GetPushLogs 获取推送记录列表
|
||||
func (s *messagePushService) GetPushLogs(ctx context.Context, req *models.MessagePushListRequest) (*models.MessagePushListResponse, error) {
|
||||
// 参数校验
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 || req.PageSize > 100 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
|
||||
// 调用存储层
|
||||
logs, total, err := s.storage.List(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取推送记录失败: %w", err)
|
||||
}
|
||||
|
||||
return &models.MessagePushListResponse{
|
||||
List: logs,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPushLogByID 根据ID获取推送记录
|
||||
func (s *messagePushService) GetPushLogByID(ctx context.Context, id int64) (*models.MessagePushLog, error) {
|
||||
if id <= 0 {
|
||||
return nil, fmt.Errorf("推送记录ID无效")
|
||||
}
|
||||
log, err := s.storage.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
// SearchUsers 搜索用户
|
||||
func (s *messagePushService) SearchUsers(ctx context.Context, req *models.UserSearchRequest) (*models.UserSearchResponse, error) {
|
||||
// 参数校验
|
||||
if req.Limit <= 0 || req.Limit > 1000 {
|
||||
req.Limit = 20
|
||||
}
|
||||
|
||||
users, err := s.storage.SearchUsers(ctx, req.Keyword, req.Limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("搜索用户失败: %w", err)
|
||||
}
|
||||
|
||||
return &models.UserSearchResponse{
|
||||
Users: users,
|
||||
Total: len(users),
|
||||
}, nil
|
||||
}
|
||||
131
internal/services/page_service.go
Normal file
131
internal/services/page_service.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go.uber.org/zap"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
"goalfymax-admin/pkg/utils"
|
||||
)
|
||||
|
||||
// PageService 页面服务接口
|
||||
type PageService interface {
|
||||
Create(req *models.PageCreateRequest) (*models.Page, error)
|
||||
GetByID(id uint) (*models.Page, error)
|
||||
Update(id uint, req *models.PageUpdateRequest) (*models.Page, error)
|
||||
Delete(id uint) error
|
||||
List(req *models.PageListRequest) ([]models.Page, int64, error)
|
||||
}
|
||||
|
||||
type pageService struct {
|
||||
pageStorage storage.PageStorage
|
||||
logger *utils.Logger
|
||||
}
|
||||
|
||||
// NewPageService 创建页面服务实例
|
||||
func NewPageService(pageStorage storage.PageStorage, logger *utils.Logger) PageService {
|
||||
return &pageService{
|
||||
pageStorage: pageStorage,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建页面
|
||||
func (s *pageService) Create(req *models.PageCreateRequest) (*models.Page, error) {
|
||||
// 检查页面路径是否已存在
|
||||
_, err := s.pageStorage.GetByPath(req.Path)
|
||||
if err == nil {
|
||||
return nil, errors.New("页面路径已存在")
|
||||
}
|
||||
|
||||
// 创建页面
|
||||
page := &models.Page{
|
||||
Name: req.Name,
|
||||
Path: req.Path,
|
||||
Icon: req.Icon,
|
||||
SortOrder: req.SortOrder,
|
||||
IsActive: req.IsActive,
|
||||
}
|
||||
|
||||
err = s.pageStorage.Create(page)
|
||||
if err != nil {
|
||||
s.logger.Error("创建页面失败", zap.Error(err))
|
||||
return nil, errors.New("创建页面失败")
|
||||
}
|
||||
|
||||
s.logger.Info("页面创建成功", zap.String("name", page.Name))
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取页面
|
||||
func (s *pageService) GetByID(id uint) (*models.Page, error) {
|
||||
page, err := s.pageStorage.GetByID(id)
|
||||
if err != nil {
|
||||
s.logger.Error("获取页面失败", zap.Uint("id", id), zap.Error(err))
|
||||
return nil, errors.New("页面不存在")
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// Update 更新页面
|
||||
func (s *pageService) Update(id uint, req *models.PageUpdateRequest) (*models.Page, error) {
|
||||
// 获取现有页面
|
||||
page, err := s.pageStorage.GetByID(id)
|
||||
if err != nil {
|
||||
s.logger.Error("页面不存在", zap.Uint("id", id), zap.Error(err))
|
||||
return nil, errors.New("页面不存在")
|
||||
}
|
||||
|
||||
// 如果路径发生变化,检查新路径是否已存在
|
||||
if req.Path != page.Path {
|
||||
_, err := s.pageStorage.GetByPath(req.Path)
|
||||
if err == nil {
|
||||
return nil, errors.New("页面路径已被其他页面使用")
|
||||
}
|
||||
page.Path = req.Path
|
||||
}
|
||||
|
||||
// 更新页面信息
|
||||
page.Name = req.Name
|
||||
page.Icon = req.Icon
|
||||
page.SortOrder = req.SortOrder
|
||||
page.IsActive = req.IsActive
|
||||
|
||||
err = s.pageStorage.Update(page)
|
||||
if err != nil {
|
||||
s.logger.Error("更新页面失败", zap.Error(err))
|
||||
return nil, errors.New("更新页面失败")
|
||||
}
|
||||
|
||||
s.logger.Info("页面更新成功", zap.Uint("page_id", id))
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// Delete 删除页面
|
||||
func (s *pageService) Delete(id uint) error {
|
||||
// 检查页面是否存在
|
||||
_, err := s.pageStorage.GetByID(id)
|
||||
if err != nil {
|
||||
s.logger.Error("页面不存在", zap.Uint("id", id), zap.Error(err))
|
||||
return errors.New("页面不存在")
|
||||
}
|
||||
|
||||
err = s.pageStorage.Delete(id)
|
||||
if err != nil {
|
||||
s.logger.Error("删除页面失败", zap.Error(err))
|
||||
return errors.New("删除页面失败")
|
||||
}
|
||||
|
||||
s.logger.Info("页面删除成功", zap.Uint("page_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 获取页面列表
|
||||
func (s *pageService) List(req *models.PageListRequest) ([]models.Page, int64, error) {
|
||||
pages, total, err := s.pageStorage.List(req)
|
||||
if err != nil {
|
||||
s.logger.Error("获取页面列表失败", zap.Error(err))
|
||||
return nil, 0, errors.New("获取页面列表失败")
|
||||
}
|
||||
return pages, total, nil
|
||||
}
|
||||
128
internal/services/quota_service.go
Normal file
128
internal/services/quota_service.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// QuotaService 配额服务接口
|
||||
type QuotaService interface {
|
||||
GetQuotaHistory(req *models.QuotaHistoryRequest) (*models.QuotaHistoryResponse, error)
|
||||
HealthCheck() error
|
||||
GetQuotaRules() (*models.QuotaRulesResponse, error)
|
||||
CreateQuotaRule(body any) (*models.QuotaRulesResponse, error)
|
||||
UpdateQuotaRule(id string, body any) (*models.QuotaRulesResponse, error)
|
||||
DeleteQuotaRule(id string) (*models.QuotaRulesResponse, error)
|
||||
}
|
||||
|
||||
type quotaService struct {
|
||||
gatewayClient *GatewayClient
|
||||
logger *utils.Logger
|
||||
}
|
||||
|
||||
// NewQuotaService 创建配额服务实例
|
||||
func NewQuotaService(gatewayClient *GatewayClient, logger *utils.Logger) QuotaService {
|
||||
return "aService{
|
||||
gatewayClient: gatewayClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// GetQuotaHistory 获取配额历史数据
|
||||
func (s *quotaService) GetQuotaHistory(req *models.QuotaHistoryRequest) (*models.QuotaHistoryResponse, error) {
|
||||
// 验证请求参数
|
||||
if err := s.validateQuotaHistoryRequest(req); err != nil {
|
||||
s.logger.Error("配额历史请求参数验证失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 调用网关客户端
|
||||
response, err := s.gatewayClient.GetQuotaHistory(req)
|
||||
if err != nil {
|
||||
s.logger.Error("获取配额历史数据失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("配额历史数据获取成功",
|
||||
zap.String("start_date", req.StartDate),
|
||||
zap.String("end_date", req.EndDate),
|
||||
zap.Int("data_count", len(response.Data)),
|
||||
)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// HealthCheck 健康检查
|
||||
func (s *quotaService) HealthCheck() error {
|
||||
err := s.gatewayClient.HealthCheck()
|
||||
if err != nil {
|
||||
s.logger.Error("配额服务健康检查失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("配额服务健康检查成功")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetQuotaRules 获取配额规则列表
|
||||
func (s *quotaService) GetQuotaRules() (*models.QuotaRulesResponse, error) {
|
||||
resp, err := s.gatewayClient.GetQuotaRules("")
|
||||
if err != nil {
|
||||
s.logger.Error("获取配额规则失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *quotaService) CreateQuotaRule(body any) (*models.QuotaRulesResponse, error) {
|
||||
return s.gatewayClient.CreateQuotaRule("", body)
|
||||
}
|
||||
|
||||
func (s *quotaService) UpdateQuotaRule(id string, body any) (*models.QuotaRulesResponse, error) {
|
||||
if id == "" {
|
||||
return nil, errors.New("缺少规则ID")
|
||||
}
|
||||
return s.gatewayClient.UpdateQuotaRule("", id, body)
|
||||
}
|
||||
|
||||
func (s *quotaService) DeleteQuotaRule(id string) (*models.QuotaRulesResponse, error) {
|
||||
if id == "" {
|
||||
return nil, errors.New("缺少规则ID")
|
||||
}
|
||||
return s.gatewayClient.DeleteQuotaRule("", id)
|
||||
}
|
||||
|
||||
// validateQuotaHistoryRequest 验证配额历史请求参数
|
||||
func (s *quotaService) validateQuotaHistoryRequest(req *models.QuotaHistoryRequest) error {
|
||||
if req.StartDate == "" {
|
||||
return errors.New("开始日期不能为空")
|
||||
}
|
||||
|
||||
if req.EndDate == "" {
|
||||
return errors.New("结束日期不能为空")
|
||||
}
|
||||
|
||||
// 验证日期格式 (简单验证,实际项目中可以使用更严格的验证)
|
||||
if len(req.StartDate) != 10 || len(req.EndDate) != 10 {
|
||||
return errors.New("日期格式不正确,应为 YYYY-MM-DD")
|
||||
}
|
||||
|
||||
// 验证周期参数
|
||||
if req.Period != "" && req.Period != "daily" && req.Period != "monthly" {
|
||||
return errors.New("周期参数只能是 daily 或 monthly")
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if req.Period == "" {
|
||||
req.Period = "daily"
|
||||
}
|
||||
|
||||
if len(req.GroupBy) == 0 {
|
||||
req.GroupBy = []string{"user_id"}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
165
internal/services/rbac_service.go
Normal file
165
internal/services/rbac_service.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go.uber.org/zap"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
"goalfymax-admin/pkg/utils"
|
||||
)
|
||||
|
||||
// RBACService 简化的RBAC服务接口
|
||||
type RBACService interface {
|
||||
// 角色页面权限管理
|
||||
AssignRolePagePermissions(req *models.RolePagePermissionAssignRequest) error
|
||||
RemoveRolePagePermissions(roleID uint, pageIDs []uint) error
|
||||
GetRolePagePermissions(roleID uint) ([]models.Page, error)
|
||||
GetRolePagePermissionIDs(roleID uint) ([]uint, error)
|
||||
|
||||
// 页面权限检查
|
||||
CheckUserPagePermission(userID uint, pagePath string) (bool, error)
|
||||
GetUserAccessiblePages(userID uint) ([]string, error)
|
||||
GetUserPermissionsResponse(userID uint) (*models.UserPermissionsResponse, error)
|
||||
GetRolePagePermissionsResponse(roleID uint) (*models.RolePagePermissionsResponse, error)
|
||||
|
||||
// 角色管理
|
||||
GetRoleByID(roleID uint) (*models.Role, error)
|
||||
}
|
||||
|
||||
type rbacService struct {
|
||||
rbacStorage storage.RBACStorage
|
||||
userStorage storage.UserStorage
|
||||
logger *utils.Logger
|
||||
}
|
||||
|
||||
// NewRBACService 创建RBAC服务实例
|
||||
func NewRBACService(rbacStorage storage.RBACStorage, userStorage storage.UserStorage, logger *utils.Logger) RBACService {
|
||||
return &rbacService{
|
||||
rbacStorage: rbacStorage,
|
||||
userStorage: userStorage,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// AssignRolePagePermissions 分配角色页面权限
|
||||
func (s *rbacService) AssignRolePagePermissions(req *models.RolePagePermissionAssignRequest) error {
|
||||
err := s.rbacStorage.AssignRolePagePermissions(req.RoleID, req.PageIDs)
|
||||
if err != nil {
|
||||
s.logger.Error("分配角色页面权限失败", zap.Uint("roleID", req.RoleID), zap.Uints("pageIDs", req.PageIDs), zap.Error(err))
|
||||
return errors.New("分配角色页面权限失败")
|
||||
}
|
||||
|
||||
s.logger.Info("分配角色页面权限成功", zap.Uint("roleID", req.RoleID), zap.Uints("pageIDs", req.PageIDs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveRolePagePermissions 移除角色页面权限
|
||||
func (s *rbacService) RemoveRolePagePermissions(roleID uint, pageIDs []uint) error {
|
||||
err := s.rbacStorage.RemoveRolePagePermissions(roleID, pageIDs)
|
||||
if err != nil {
|
||||
s.logger.Error("移除角色页面权限失败", zap.Uint("roleID", roleID), zap.Uints("pageIDs", pageIDs), zap.Error(err))
|
||||
return errors.New("移除角色页面权限失败")
|
||||
}
|
||||
|
||||
s.logger.Info("移除角色页面权限成功", zap.Uint("roleID", roleID), zap.Uints("pageIDs", pageIDs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRolePagePermissions 获取角色页面权限
|
||||
func (s *rbacService) GetRolePagePermissions(roleID uint) ([]models.Page, error) {
|
||||
pages, err := s.rbacStorage.GetRolePagePermissions(roleID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取角色页面权限失败", zap.Uint("roleID", roleID), zap.Error(err))
|
||||
return nil, errors.New("获取角色页面权限失败")
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// GetRolePagePermissionIDs 获取角色页面权限ID列表
|
||||
func (s *rbacService) GetRolePagePermissionIDs(roleID uint) ([]uint, error) {
|
||||
pageIDs, err := s.rbacStorage.GetRolePagePermissionIDs(roleID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取角色页面权限ID失败", zap.Uint("roleID", roleID), zap.Error(err))
|
||||
return nil, errors.New("获取角色页面权限ID失败")
|
||||
}
|
||||
return pageIDs, nil
|
||||
}
|
||||
|
||||
// CheckUserPagePermission 检查用户页面权限
|
||||
func (s *rbacService) CheckUserPagePermission(userID uint, pagePath string) (bool, error) {
|
||||
hasPermission, err := s.rbacStorage.CheckUserRolePagePermission(userID, pagePath)
|
||||
if err != nil {
|
||||
s.logger.Error("检查用户页面权限失败", zap.Uint("userID", userID), zap.String("pagePath", pagePath), zap.Error(err))
|
||||
return false, errors.New("检查用户页面权限失败")
|
||||
}
|
||||
return hasPermission, nil
|
||||
}
|
||||
|
||||
// GetUserAccessiblePages 获取用户可访问页面
|
||||
func (s *rbacService) GetUserAccessiblePages(userID uint) ([]string, error) {
|
||||
pages, err := s.rbacStorage.GetUserRoleAccessiblePages(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取用户可访问页面失败", zap.Uint("userID", userID), zap.Error(err))
|
||||
return nil, errors.New("获取用户可访问页面失败")
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// GetUserPermissionsResponse 获取用户权限响应
|
||||
func (s *rbacService) GetUserPermissionsResponse(userID uint) (*models.UserPermissionsResponse, error) {
|
||||
// 获取用户信息
|
||||
user, err := s.userStorage.GetByID(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取用户信息失败", zap.Uint("userID", userID), zap.Error(err))
|
||||
return nil, errors.New("获取用户信息失败")
|
||||
}
|
||||
|
||||
// 查询角色信息
|
||||
role, err := s.rbacStorage.GetRoleByID(user.RoleID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取角色信息失败", zap.Uint("roleID", user.RoleID), zap.Error(err))
|
||||
return nil, errors.New("获取角色信息失败")
|
||||
}
|
||||
|
||||
// 获取用户可访问页面
|
||||
pagePaths, err := s.GetUserAccessiblePages(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取用户可访问页面失败", zap.Uint("userID", userID), zap.Error(err))
|
||||
return nil, errors.New("获取用户可访问页面失败")
|
||||
}
|
||||
|
||||
// 转换页面路径为页面对象
|
||||
var pages []models.Page
|
||||
for _, path := range pagePaths {
|
||||
pages = append(pages, models.Page{Path: path})
|
||||
}
|
||||
|
||||
return &models.UserPermissionsResponse{
|
||||
User: *user,
|
||||
Roles: []models.Role{*role},
|
||||
Pages: pages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetRolePagePermissionsResponse 获取角色页面权限响应
|
||||
func (s *rbacService) GetRolePagePermissionsResponse(roleID uint) (*models.RolePagePermissionsResponse, error) {
|
||||
// 获取角色信息(这里需要从角色存储中获取,暂时简化)
|
||||
role := models.Role{BaseModel: models.BaseModel{ID: roleID}}
|
||||
|
||||
// 获取角色页面权限
|
||||
pages, err := s.GetRolePagePermissions(roleID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取角色页面权限失败", zap.Uint("roleID", roleID), zap.Error(err))
|
||||
return nil, errors.New("获取角色页面权限失败")
|
||||
}
|
||||
|
||||
return &models.RolePagePermissionsResponse{
|
||||
Role: role,
|
||||
Pages: pages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetRoleByID 根据ID获取角色
|
||||
func (s *rbacService) GetRoleByID(roleID uint) (*models.Role, error) {
|
||||
return s.rbacStorage.GetRoleByID(roleID)
|
||||
}
|
||||
137
internal/services/role_service.go
Normal file
137
internal/services/role_service.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go.uber.org/zap"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
"goalfymax-admin/pkg/utils"
|
||||
)
|
||||
|
||||
// RoleService 角色服务接口
|
||||
type RoleService interface {
|
||||
Create(req *models.RoleCreateRequest) (*models.Role, error)
|
||||
GetByID(id uint) (*models.Role, error)
|
||||
Update(id uint, req *models.RoleUpdateRequest) (*models.Role, error)
|
||||
Delete(id uint) error
|
||||
List(req *models.RoleListRequest) ([]models.Role, int64, error)
|
||||
UpdateStatus(id uint, status int) error
|
||||
}
|
||||
|
||||
type roleService struct {
|
||||
roleStorage storage.RoleStorage
|
||||
logger *utils.Logger
|
||||
}
|
||||
|
||||
// NewRoleService 创建角色服务实例
|
||||
func NewRoleService(roleStorage storage.RoleStorage, logger *utils.Logger) RoleService {
|
||||
return &roleService{
|
||||
roleStorage: roleStorage,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建角色
|
||||
func (s *roleService) Create(req *models.RoleCreateRequest) (*models.Role, error) {
|
||||
// 检查角色名称是否已存在
|
||||
_, err := s.roleStorage.GetByName(req.Name)
|
||||
if err == nil {
|
||||
return nil, errors.New("角色名称已存在")
|
||||
}
|
||||
|
||||
// 创建角色
|
||||
role := &models.Role{
|
||||
Name: req.Name,
|
||||
Level: req.Level,
|
||||
Description: req.Description,
|
||||
IsDefault: req.IsDefault,
|
||||
}
|
||||
|
||||
err = s.roleStorage.Create(role)
|
||||
if err != nil {
|
||||
s.logger.Error("创建角色失败", zap.Error(err))
|
||||
return nil, errors.New("创建角色失败")
|
||||
}
|
||||
|
||||
s.logger.Info("角色创建成功", zap.String("name", role.Name))
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取角色
|
||||
func (s *roleService) GetByID(id uint) (*models.Role, error) {
|
||||
return s.roleStorage.GetByID(id)
|
||||
}
|
||||
|
||||
// Update 更新角色
|
||||
func (s *roleService) Update(id uint, req *models.RoleUpdateRequest) (*models.Role, error) {
|
||||
// 获取角色信息
|
||||
role, err := s.roleStorage.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.New("角色不存在")
|
||||
}
|
||||
|
||||
// 检查角色名称是否已被其他角色使用
|
||||
if req.Name != role.Name {
|
||||
existingRole, err := s.roleStorage.GetByName(req.Name)
|
||||
if err == nil && existingRole.ID != id {
|
||||
return nil, errors.New("角色名称已被其他角色使用")
|
||||
}
|
||||
role.Name = req.Name
|
||||
}
|
||||
|
||||
// 更新角色信息
|
||||
role.Name = req.Name
|
||||
role.Level = req.Level
|
||||
role.Description = req.Description
|
||||
role.IsDefault = req.IsDefault
|
||||
|
||||
err = s.roleStorage.Update(role)
|
||||
if err != nil {
|
||||
s.logger.Error("更新角色失败", zap.Error(err))
|
||||
return nil, errors.New("更新角色失败")
|
||||
}
|
||||
|
||||
s.logger.Info("角色更新成功", zap.Uint("role_id", id))
|
||||
return role, nil
|
||||
}
|
||||
|
||||
// Delete 删除角色
|
||||
func (s *roleService) Delete(id uint) error {
|
||||
// 检查角色是否存在
|
||||
_, err := s.roleStorage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("角色不存在")
|
||||
}
|
||||
|
||||
err = s.roleStorage.Delete(id)
|
||||
if err != nil {
|
||||
s.logger.Error("删除角色失败", zap.Error(err))
|
||||
return errors.New("删除角色失败")
|
||||
}
|
||||
|
||||
s.logger.Info("角色删除成功", zap.Uint("role_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 获取角色列表
|
||||
func (s *roleService) List(req *models.RoleListRequest) ([]models.Role, int64, error) {
|
||||
return s.roleStorage.List(req)
|
||||
}
|
||||
|
||||
// UpdateStatus 更新角色状态
|
||||
func (s *roleService) UpdateStatus(id uint, status int) error {
|
||||
// 检查角色是否存在
|
||||
_, err := s.roleStorage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("角色不存在")
|
||||
}
|
||||
|
||||
err = s.roleStorage.UpdateStatus(id, status)
|
||||
if err != nil {
|
||||
s.logger.Error("更新角色状态失败", zap.Error(err))
|
||||
return errors.New("更新角色状态失败")
|
||||
}
|
||||
|
||||
s.logger.Info("角色状态更新成功", zap.Uint("role_id", id), zap.Int("status", status))
|
||||
return nil
|
||||
}
|
||||
170
internal/services/sso_admin_service.go
Normal file
170
internal/services/sso_admin_service.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"goalfymax-admin/internal/config"
|
||||
)
|
||||
|
||||
// SSOAdminUserCreateRequest SSO创建用户请求
|
||||
type SSOAdminUserCreateRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// SSOAdminUserCreateResponse SSO创建用户响应
|
||||
type SSOAdminUserCreateResponse struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// SSOAdminUserRoleAssignRequest SSO用户角色分配请求
|
||||
type SSOAdminUserRoleAssignRequest struct {
|
||||
UserID int `json:"user_id"`
|
||||
RoleID int `json:"role_id"`
|
||||
}
|
||||
|
||||
// SSOAdminUserRoleAssignResponse SSO用户角色分配响应
|
||||
type SSOAdminUserRoleAssignResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// SSOAdminService SSO管理服务接口
|
||||
type SSOAdminService interface {
|
||||
CreateUser(ctx context.Context, req *SSOAdminUserCreateRequest) (*SSOAdminUserCreateResponse, error)
|
||||
SetSystemRole(ctx context.Context, userID int, systemRole string) error
|
||||
}
|
||||
|
||||
// ssoAdminService SSO管理服务实现
|
||||
type ssoAdminService struct {
|
||||
baseURL string
|
||||
adminToken string
|
||||
timeout time.Duration
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewSSOAdminService 创建SSO管理服务实例
|
||||
func NewSSOAdminService() SSOAdminService {
|
||||
cfg := config.GetConfig()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: cfg.SSO.Timeout,
|
||||
}
|
||||
|
||||
return &ssoAdminService{
|
||||
baseURL: cfg.SSO.SSOServerURL,
|
||||
adminToken: cfg.SSO.AdminToken,
|
||||
timeout: cfg.SSO.Timeout,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateUser 创建SSO用户
|
||||
func (s *ssoAdminService) CreateUser(ctx context.Context, req *SSOAdminUserCreateRequest) (*SSOAdminUserCreateResponse, error) {
|
||||
url := fmt.Sprintf("%s/api/admin/users", s.baseURL)
|
||||
|
||||
// 构造请求体
|
||||
reqBody, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("序列化请求体失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建HTTP请求
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建HTTP请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.adminToken))
|
||||
httpReq.Header.Set("Accept", "*/*")
|
||||
httpReq.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
|
||||
httpReq.Header.Set("Cache-Control", "no-cache")
|
||||
httpReq.Header.Set("Pragma", "no-cache")
|
||||
|
||||
// 发送请求
|
||||
resp, err := s.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("发送HTTP请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应体
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取响应体失败: %w", err)
|
||||
}
|
||||
|
||||
// 检查HTTP状态码
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return nil, fmt.Errorf("SSO API返回错误状态码 %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
var response SSOAdminUserCreateResponse
|
||||
if err := json.Unmarshal(respBody, &response); err != nil {
|
||||
return nil, fmt.Errorf("解析SSO响应失败: %w", err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// SetSystemRole 设置系统角色(custom/sys_admin)
|
||||
func (s *ssoAdminService) SetSystemRole(ctx context.Context, userID int, systemRole string) error {
|
||||
url := fmt.Sprintf("%s/api/admin/users/%d/role", s.baseURL, userID)
|
||||
|
||||
// 构造请求体
|
||||
reqBodyBytes, err := json.Marshal(map[string]string{
|
||||
"system_role": systemRole,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("序列化请求体失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建HTTP请求
|
||||
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(reqBodyBytes))
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建HTTP请求失败: %w", err)
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.adminToken))
|
||||
httpReq.Header.Set("Accept", "*/*")
|
||||
httpReq.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
|
||||
httpReq.Header.Set("Cache-Control", "no-cache")
|
||||
httpReq.Header.Set("Pragma", "no-cache")
|
||||
|
||||
// 发送请求
|
||||
resp, err := s.client.Do(httpReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("发送HTTP请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应体
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取响应体失败: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return fmt.Errorf("SSO系统角色API返回错误状态码 %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
// 允许无 success 包装的简易响应,这里不做进一步结构校验
|
||||
return nil
|
||||
}
|
||||
618
internal/services/sso_service.go
Normal file
618
internal/services/sso_service.go
Normal file
@@ -0,0 +1,618 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
"goalfymax-admin/pkg/middleware"
|
||||
"goalfymax-admin/pkg/utils"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SSOService SSO服务接口
|
||||
type SSOService interface {
|
||||
// 登录相关
|
||||
InitiateLogin(ctx context.Context) (*models.SSOLoginResponse, error)
|
||||
HandleCallback(ctx context.Context, req *models.SSOCallbackRequest) (*models.SSOCallbackResponse, error)
|
||||
RefreshToken(ctx context.Context, req *models.RefreshTokenRequest) (*models.RefreshTokenResponse, error)
|
||||
Logout(ctx context.Context, token string) (*models.LogoutResponse, error)
|
||||
GetUserInfo(ctx context.Context, token string) (*models.UserInfoResponse, error)
|
||||
|
||||
// 登录信息管理
|
||||
RecordUserLogin(ctx context.Context, req *UserLoginRequest) error
|
||||
UserLogout(ctx context.Context, userID int, uuid string) error
|
||||
GetUserLoginInfo(ctx context.Context, userID int) (*models.LoginInfo, error)
|
||||
IsUserOnline(ctx context.Context, userID int) (bool, error)
|
||||
GetOnlineUsers(ctx context.Context) ([]*models.LoginInfo, error)
|
||||
GetOnlineUserCount(ctx context.Context) (int64, error)
|
||||
BatchUserLogout(ctx context.Context, userIDs []int) error
|
||||
}
|
||||
|
||||
// UserLoginRequest 用户登录请求结构体
|
||||
type UserLoginRequest struct {
|
||||
UserID int `json:"user_id" binding:"required"`
|
||||
UserName string `json:"user_name" binding:"required,max=100"`
|
||||
Email string `json:"email" binding:"required,email,max=255"`
|
||||
UUID string `json:"uuid" binding:"required"`
|
||||
}
|
||||
|
||||
type ssoService struct {
|
||||
client *middleware.SSOClient
|
||||
pkceStateStorage storage.PKCEStateStorage
|
||||
loginInfoStorage storage.LoginInfoStorage
|
||||
rbacService RBACService
|
||||
logger *utils.Logger
|
||||
}
|
||||
|
||||
// NewSSOService 创建SSO服务实例
|
||||
func NewSSOService(client *middleware.SSOClient, pkceStateStorage storage.PKCEStateStorage, loginInfoStorage storage.LoginInfoStorage, rbacService RBACService, logger *utils.Logger) SSOService {
|
||||
return &ssoService{
|
||||
client: client,
|
||||
pkceStateStorage: pkceStateStorage,
|
||||
loginInfoStorage: loginInfoStorage,
|
||||
rbacService: rbacService,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// InitiateLogin 初始化SSO登录
|
||||
func (s *ssoService) InitiateLogin(ctx context.Context) (*models.SSOLoginResponse, error) {
|
||||
// 生成状态参数
|
||||
state := fmt.Sprintf("state_%d", time.Now().UnixNano())
|
||||
|
||||
// 获取授权URL
|
||||
authURL, codeVerifier, err := s.client.GetAuthorizationURL(state)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to generate authorization URL", zap.Error(err))
|
||||
return nil, fmt.Errorf("生成授权URL失败: %w", err)
|
||||
}
|
||||
|
||||
// 将state和codeVerifier存储到数据库
|
||||
pkceState := &models.PKCEState{
|
||||
State: state,
|
||||
CodeVerifier: codeVerifier,
|
||||
}
|
||||
|
||||
if err := s.pkceStateStorage.Create(pkceState); err != nil {
|
||||
s.logger.Error("failed to store PKCE state", zap.Error(err))
|
||||
return nil, fmt.Errorf("存储PKCE状态失败: %w", err)
|
||||
}
|
||||
|
||||
return &models.SSOLoginResponse{
|
||||
Success: true,
|
||||
Message: "SSO login initiated",
|
||||
AuthURL: authURL,
|
||||
State: state,
|
||||
CodeVerifier: "", // 不返回codeVerifier到前端,保护安全
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleCallback 处理SSO回调
|
||||
func (s *ssoService) HandleCallback(ctx context.Context, req *models.SSOCallbackRequest) (*models.SSOCallbackResponse, error) {
|
||||
// 从数据库获取PKCE状态信息
|
||||
pkceState, err := s.pkceStateStorage.GetByState(req.State)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get PKCE state", zap.String("state", req.State), zap.Error(err))
|
||||
return nil, fmt.Errorf("无效或过期的状态参数")
|
||||
}
|
||||
|
||||
// 交换令牌
|
||||
tokenResp, err := s.client.ExchangeCodeForToken(ctx, req.Code, pkceState.CodeVerifier)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to exchange token", zap.Error(err))
|
||||
return nil, fmt.Errorf("令牌交换失败: %w", err)
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
userInfo, err := s.client.GetUserInfo(ctx, tokenResp.AccessToken)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get user info", zap.Error(err))
|
||||
return nil, fmt.Errorf("获取用户信息失败: %w", err)
|
||||
}
|
||||
|
||||
// 记录用户登录信息
|
||||
uid, err := strconv.Atoi(userInfo.Sub)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to convert user id", zap.String("sub", userInfo.Sub), zap.Error(err))
|
||||
return nil, fmt.Errorf("无效的用户ID")
|
||||
}
|
||||
|
||||
// 计算本次登录应赋予的角色ID:sys_admin → 5;否则 → 0
|
||||
assignedRoleID := uint(0)
|
||||
if len(userInfo.Roles) > 0 {
|
||||
for _, r := range userInfo.Roles {
|
||||
if r.Name == "sys_admin" {
|
||||
assignedRoleID = uint(5)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if assignedRoleID == 0 {
|
||||
s.logger.Info("本次登录未检测到 sys_admin 角色,赋予无权限角色ID=0", zap.Int("userID", uid))
|
||||
} else {
|
||||
s.logger.Info("本次登录检测到 sys_admin 角色,赋予角色ID=5", zap.Int("userID", uid))
|
||||
}
|
||||
|
||||
// 查找或创建用户记录,并根据本次判定设置 role_id(sys_admin=5,否则=0)
|
||||
_, err = s.findOrCreateUserWithRole(uint(uid), userInfo, assignedRoleID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to find or create user", zap.Error(err))
|
||||
return nil, fmt.Errorf("创建或查找用户失败: %w", err)
|
||||
}
|
||||
|
||||
uuid := uuid.NewString()
|
||||
err = s.RecordUserLogin(ctx, &UserLoginRequest{
|
||||
UserID: uid,
|
||||
UserName: userInfo.Name,
|
||||
Email: userInfo.Email,
|
||||
UUID: uuid,
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Error("failed to record user login", zap.Int("user_id", uid), zap.Error(err))
|
||||
// 不返回错误,继续处理
|
||||
}
|
||||
|
||||
// 获取用户可访问页面信息
|
||||
userPages, err := s.rbacService.GetUserAccessiblePages(uint(uid))
|
||||
if err != nil {
|
||||
s.logger.Warn("获取用户页面权限失败", zap.Error(err))
|
||||
userPages = []string{}
|
||||
}
|
||||
s.logger.Info("获取用户可访问页面", zap.Uint("userID", uint(uid)), zap.Strings("pages", userPages))
|
||||
|
||||
// 获取用户角色信息(用于前端体验判断,后端仍以接口鉴权为准)
|
||||
var user models.User
|
||||
var userRoleName string
|
||||
err = storage.DB.Where("id = ?", uint(uid)).First(&user).Error
|
||||
if err != nil {
|
||||
s.logger.Warn("获取用户信息失败", zap.Error(err))
|
||||
} else {
|
||||
role, rerr := s.rbacService.GetRoleByID(user.RoleID)
|
||||
if rerr != nil {
|
||||
s.logger.Warn("获取角色信息失败", zap.Error(rerr))
|
||||
} else {
|
||||
userRoleName = role.Name
|
||||
s.logger.Info("获取用户角色", zap.Uint("userID", uint(uid)), zap.String("roleName", userRoleName))
|
||||
}
|
||||
}
|
||||
|
||||
// 构建包含页面权限的用户信息
|
||||
userInfoWithPages := &models.UserInfo{
|
||||
Sub: userInfo.Sub,
|
||||
Name: userInfo.Name,
|
||||
Email: userInfo.Email,
|
||||
PreferredUsername: userInfo.PreferredUsername,
|
||||
Pages: convertPagesFromPaths(userPages),
|
||||
Roles: []models.Role{},
|
||||
}
|
||||
|
||||
if userRoleName != "" {
|
||||
userInfoWithPages.Roles = append(userInfoWithPages.Roles, models.Role{Name: userRoleName})
|
||||
}
|
||||
|
||||
// 清理PKCE状态
|
||||
_ = s.pkceStateStorage.DeleteByState(req.State)
|
||||
|
||||
return &models.SSOCallbackResponse{
|
||||
Success: true,
|
||||
Message: "SSO login successful",
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
IDToken: tokenResp.IDToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
UserInfo: userInfoWithPages,
|
||||
UUID: uuid,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// convertPagesFromPaths 将页面路径转换为页面对象
|
||||
func convertPagesFromPaths(paths []string) []models.Page {
|
||||
var pages []models.Page
|
||||
|
||||
fmt.Printf("🔍 [convertPagesFromPaths] 输入路径: %v\n", paths)
|
||||
|
||||
// 从数据库获取完整的页面信息
|
||||
err := storage.DB.Where("path IN ? AND is_active = TRUE AND deleted_at IS NULL", paths).Find(&pages).Error
|
||||
if err != nil {
|
||||
fmt.Printf("❌ [convertPagesFromPaths] 数据库查询失败: %v\n", err)
|
||||
// 如果数据库查询失败,至少返回路径信息
|
||||
for _, path := range paths {
|
||||
pages = append(pages, models.Page{
|
||||
Path: path,
|
||||
IsActive: true, // 默认设置为激活状态
|
||||
})
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("✅ [convertPagesFromPaths] 数据库查询成功,找到 %d 个页面\n", len(pages))
|
||||
for _, page := range pages {
|
||||
fmt.Printf(" - 页面: %s, 激活状态: %v\n", page.Path, page.IsActive)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
// RefreshToken 刷新令牌
|
||||
func (s *ssoService) RefreshToken(ctx context.Context, req *models.RefreshTokenRequest) (*models.RefreshTokenResponse, error) {
|
||||
// 刷新令牌
|
||||
tokenResp, err := s.client.RefreshToken(ctx, req.RefreshToken)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to refresh token", zap.Error(err))
|
||||
return nil, fmt.Errorf("令牌刷新失败: %w", err)
|
||||
}
|
||||
|
||||
return &models.RefreshTokenResponse{
|
||||
Success: true,
|
||||
Message: "Token refreshed successfully",
|
||||
AccessToken: tokenResp.AccessToken,
|
||||
IDToken: tokenResp.IDToken,
|
||||
RefreshToken: tokenResp.RefreshToken,
|
||||
ExpiresIn: tokenResp.ExpiresIn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Logout 登出
|
||||
func (s *ssoService) Logout(ctx context.Context, token string) (*models.LogoutResponse, error) {
|
||||
// 获取用户信息用于记录登出
|
||||
userInfo, err := s.client.GetUserInfo(ctx, token)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get user info during logout", zap.Error(err))
|
||||
// 继续执行登出,不中断流程
|
||||
}
|
||||
|
||||
// 调用SSO服务登出
|
||||
err = s.client.Logout(ctx, token)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to logout", zap.Error(err))
|
||||
return nil, fmt.Errorf("登出失败: %w", err)
|
||||
}
|
||||
|
||||
// 记录用户登出信息
|
||||
if userInfo != nil {
|
||||
uid, err := strconv.Atoi(userInfo.Sub)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to convert user id during logout", zap.String("sub", userInfo.Sub), zap.Error(err))
|
||||
} else {
|
||||
// 先根据user_id查找用户的uuid
|
||||
loginInfo, err := s.loginInfoStorage.GetByUserID(uid)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get user login info during logout", zap.Int("user_id", uid), zap.Error(err))
|
||||
} else {
|
||||
// 如果UUID为空,直接根据user_id进行登出
|
||||
if loginInfo.UUID == "" {
|
||||
s.logger.Warn("UUID is empty, logging out by user_id only", zap.Int("user_id", uid))
|
||||
err = s.loginInfoStorage.SetUserOffline(uid)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to set user offline by user_id", zap.Int("user_id", uid), zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
// 使用找到的uuid进行登出
|
||||
err = s.UserLogout(ctx, uid, loginInfo.UUID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to record user logout", zap.Int("user_id", uid), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
redirectUrl := s.client.GetServerUrl() + "/oauth2/logout?redirect_uri=" + url.QueryEscape(s.client.GetRedirectUrl())
|
||||
|
||||
return &models.LogoutResponse{
|
||||
Success: true,
|
||||
Message: redirectUrl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息
|
||||
func (s *ssoService) GetUserInfo(ctx context.Context, token string) (*models.UserInfoResponse, error) {
|
||||
// 获取用户信息
|
||||
userInfo, err := s.client.GetUserInfo(ctx, token)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get user info", zap.Error(err))
|
||||
return nil, fmt.Errorf("获取用户信息失败: %w", err)
|
||||
}
|
||||
|
||||
return &models.UserInfoResponse{
|
||||
Success: true,
|
||||
Message: "User info retrieved successfully",
|
||||
UserInfo: userInfo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RecordUserLogin 记录用户登录状态
|
||||
func (s *ssoService) RecordUserLogin(ctx context.Context, req *UserLoginRequest) error {
|
||||
// 检查用户是否已存在
|
||||
existingUser, err := s.loginInfoStorage.GetByUserID(req.UserID)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
s.logger.Error("failed to get existing user login info",
|
||||
zap.Int("user_id", req.UserID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("获取用户登录信息失败: %w", err)
|
||||
}
|
||||
|
||||
if existingUser != nil {
|
||||
// 用户存在直接更新状态
|
||||
existingUser.IsOnline = true
|
||||
existingUser.UUID = req.UUID // 同时更新UUID,可能有变化
|
||||
existingUser.UserName = req.UserName
|
||||
existingUser.Email = req.Email
|
||||
|
||||
err = s.loginInfoStorage.Update(existingUser)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to update user online status",
|
||||
zap.Int("user_id", req.UserID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("更新用户在线状态失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("user login status updated successfully",
|
||||
zap.Int("user_id", req.UserID),
|
||||
zap.String("user_name", req.UserName))
|
||||
} else {
|
||||
// 用户不存在,创建新用户并设置为在线
|
||||
newLoginInfo := &models.LoginInfo{
|
||||
UserID: req.UserID,
|
||||
UserName: req.UserName,
|
||||
Email: req.Email,
|
||||
UUID: req.UUID,
|
||||
IsOnline: true,
|
||||
}
|
||||
|
||||
err = s.loginInfoStorage.Create(newLoginInfo)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to create new user login info",
|
||||
zap.Int("user_id", req.UserID),
|
||||
zap.String("user_name", req.UserName),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("创建用户登录信息失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("new user login info created successfully",
|
||||
zap.Int("user_id", req.UserID),
|
||||
zap.String("user_name", req.UserName))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserLogout 用户登出
|
||||
func (s *ssoService) UserLogout(ctx context.Context, userID int, uuid string) error {
|
||||
// 检查用户是否存在
|
||||
existingUser, err := s.loginInfoStorage.GetByUserIDAndUUID(userID, uuid)
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
s.logger.Error("failed to get user login info for logout",
|
||||
zap.Int("user_id", userID),
|
||||
zap.String("uuid", uuid),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("获取用户登录信息失败: %w", err)
|
||||
}
|
||||
|
||||
if existingUser == nil {
|
||||
// 用户不存在,不需要操作
|
||||
s.logger.Info("user not found, no logout action needed",
|
||||
zap.Int("user_id", userID),
|
||||
zap.String("uuid", uuid))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 检查用户是否已经离线
|
||||
if !existingUser.IsOnline {
|
||||
// 用户已经离线,不需要操作
|
||||
s.logger.Info("user is already offline, no action needed",
|
||||
zap.Int("user_id", userID),
|
||||
zap.String("uuid", uuid),
|
||||
zap.String("user_name", existingUser.UserName))
|
||||
return nil
|
||||
}
|
||||
|
||||
// 设置用户为离线状态
|
||||
err = s.loginInfoStorage.SetUserOffline(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to set user offline",
|
||||
zap.Int("user_id", userID),
|
||||
zap.String("user_name", existingUser.UserName),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("设置用户离线状态失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("user logout successfully",
|
||||
zap.Int("user_id", userID),
|
||||
zap.String("uuid", uuid),
|
||||
zap.String("user_name", existingUser.UserName))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserLoginInfo 获取用户登录信息
|
||||
func (s *ssoService) GetUserLoginInfo(ctx context.Context, userID int) (*models.LoginInfo, error) {
|
||||
loginInfo, err := s.loginInfoStorage.GetByUserID(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get user login info",
|
||||
zap.Int("user_id", userID),
|
||||
zap.Error(err))
|
||||
return nil, fmt.Errorf("获取用户登录信息失败: %w", err)
|
||||
}
|
||||
|
||||
return loginInfo, nil
|
||||
}
|
||||
|
||||
// IsUserOnline 检查用户是否在线
|
||||
func (s *ssoService) IsUserOnline(ctx context.Context, userID int) (bool, error) {
|
||||
loginInfo, err := s.GetUserLoginInfo(ctx, userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if loginInfo == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return loginInfo.IsOnline, nil
|
||||
}
|
||||
|
||||
// GetOnlineUsers 获取在线用户列表
|
||||
func (s *ssoService) GetOnlineUsers(ctx context.Context) ([]*models.LoginInfo, error) {
|
||||
onlineUsers, err := s.loginInfoStorage.ListOnlineUsers()
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get online users", zap.Error(err))
|
||||
return nil, fmt.Errorf("获取在线用户列表失败: %w", err)
|
||||
}
|
||||
|
||||
return onlineUsers, nil
|
||||
}
|
||||
|
||||
// GetOnlineUserCount 获取在线用户数量
|
||||
func (s *ssoService) GetOnlineUserCount(ctx context.Context) (int64, error) {
|
||||
count, err := s.loginInfoStorage.CountOnlineUsers()
|
||||
if err != nil {
|
||||
s.logger.Error("failed to count online users", zap.Error(err))
|
||||
return 0, fmt.Errorf("统计在线用户数量失败: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// BatchUserLogout 批量用户登出(可用于系统维护等场景)
|
||||
func (s *ssoService) BatchUserLogout(ctx context.Context, userIDs []int) error {
|
||||
if len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, userID := range userIDs {
|
||||
err := s.loginInfoStorage.SetUserOffline(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to set user offline in batch",
|
||||
zap.Int("user_id", userID),
|
||||
zap.Error(err))
|
||||
// 继续处理其他用户,不中断整个批量操作
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("batch user logout completed",
|
||||
zap.Ints("user_ids", userIDs))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findOrCreateUser 查找或创建用户
|
||||
func (s *ssoService) findOrCreateUserWithRole(userID uint, userInfo *models.UserInfo, assignedRoleID uint) (*models.User, error) {
|
||||
// 尝试查找现有用户
|
||||
var user models.User
|
||||
err := storage.DB.Where("id = ?", userID).First(&user).Error
|
||||
|
||||
if err == nil {
|
||||
// 用户存在,更新登录信息
|
||||
now := time.Now()
|
||||
user.LastLoginAt = &now
|
||||
user.LoginCount++
|
||||
|
||||
// 更新用户信息(如果SSO信息有变化)
|
||||
if userInfo.Name != "" && user.Nickname != userInfo.Name {
|
||||
user.Nickname = userInfo.Name
|
||||
}
|
||||
if userInfo.Email != "" && user.Email != userInfo.Email {
|
||||
user.Email = userInfo.Email
|
||||
}
|
||||
|
||||
// 同步规则:
|
||||
// - 若SSO判定为普通(0),无论当前为何值,一律降级为0
|
||||
// - 若SSO判定为sys_admin(5),仅当当前为0时升级为5(避免覆盖业务自定义角色)
|
||||
if assignedRoleID == 0 {
|
||||
if user.RoleID != 0 {
|
||||
s.logger.Info("降级用户角色(→0)", zap.Uint("userID", userID), zap.Uint("oldRoleID", user.RoleID))
|
||||
user.RoleID = 0
|
||||
} else {
|
||||
s.logger.Debug("保持0角色不变", zap.Uint("userID", userID))
|
||||
}
|
||||
} else if assignedRoleID == 5 {
|
||||
if user.RoleID == 0 {
|
||||
s.logger.Info("升级用户角色(0→5)", zap.Uint("userID", userID))
|
||||
user.RoleID = 5
|
||||
} else {
|
||||
s.logger.Debug("保持非0角色不变", zap.Uint("userID", userID), zap.Uint("currentRoleID", user.RoleID))
|
||||
}
|
||||
}
|
||||
|
||||
err = storage.DB.Save(&user).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("更新用户信息失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("用户登录信息已更新",
|
||||
zap.Uint("userID", userID),
|
||||
zap.String("username", user.Username))
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
return nil, fmt.Errorf("查询用户失败: %w", err)
|
||||
}
|
||||
|
||||
// 用户不存在,创建新用户
|
||||
now := time.Now()
|
||||
user = models.User{
|
||||
BaseModel: models.BaseModel{
|
||||
ID: userID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
Username: userInfo.PreferredUsername,
|
||||
Email: userInfo.Email,
|
||||
Nickname: userInfo.Name,
|
||||
Status: 1, // 默认启用
|
||||
SSOProvider: "default", // 可以根据实际情况设置
|
||||
LastLoginAt: &now,
|
||||
LoginCount: 1,
|
||||
RoleID: assignedRoleID,
|
||||
}
|
||||
|
||||
// 如果PreferredUsername为空,使用Email作为用户名
|
||||
if user.Username == "" {
|
||||
user.Username = userInfo.Email
|
||||
}
|
||||
|
||||
err = storage.DB.Create(&user).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建用户失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("新用户创建成功",
|
||||
zap.Uint("userID", userID),
|
||||
zap.String("username", user.Username),
|
||||
zap.String("email", user.Email))
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// assignDefaultRole 分配默认角色(L5全员)
|
||||
func (s *ssoService) assignDefaultRole(userID uint) error {
|
||||
// 获取默认角色ID(L5全员)
|
||||
var role models.Role
|
||||
err := storage.DB.Where("is_default = ?", true).First(&role).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取默认角色失败: %w", err)
|
||||
}
|
||||
|
||||
// 更新用户的角色ID
|
||||
err = storage.DB.Model(&models.User{}).Where("id = ?", userID).Update("role_id", role.ID).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("分配默认角色失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("用户已分配默认角色",
|
||||
zap.Uint("userID", userID),
|
||||
zap.Uint("roleID", role.ID),
|
||||
zap.String("roleName", role.Name))
|
||||
|
||||
return nil
|
||||
}
|
||||
145
internal/services/system_config_service.go
Normal file
145
internal/services/system_config_service.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go.uber.org/zap"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
"goalfymax-admin/pkg/utils"
|
||||
)
|
||||
|
||||
// SystemConfigService 系统配置服务接口
|
||||
type SystemConfigService interface {
|
||||
Create(req *models.SystemConfigRequest) (*models.SystemConfig, error)
|
||||
GetByID(id uint) (*models.SystemConfig, error)
|
||||
Update(id uint, req *models.SystemConfigUpdateRequest) (*models.SystemConfig, error)
|
||||
Delete(id uint) error
|
||||
List(req *models.SystemConfigListRequest) ([]models.SystemConfig, int64, error)
|
||||
UpdateStatus(id uint, status int) error
|
||||
GetAll() ([]models.SystemConfig, error)
|
||||
GetByKey(key string) (*models.SystemConfig, error)
|
||||
}
|
||||
|
||||
type systemConfigService struct {
|
||||
storage storage.SystemConfigStorage
|
||||
logger *utils.Logger
|
||||
}
|
||||
|
||||
// NewSystemConfigService 创建系统配置服务实例
|
||||
func NewSystemConfigService(storage storage.SystemConfigStorage, logger *utils.Logger) SystemConfigService {
|
||||
return &systemConfigService{
|
||||
storage: storage,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建系统配置
|
||||
func (s *systemConfigService) Create(req *models.SystemConfigRequest) (*models.SystemConfig, error) {
|
||||
// 检查配置标识是否已存在
|
||||
_, err := s.storage.GetByKey(req.Key)
|
||||
if err == nil {
|
||||
return nil, errors.New("配置标识已存在")
|
||||
}
|
||||
|
||||
// 创建配置
|
||||
config := &models.SystemConfig{
|
||||
Key: req.Key,
|
||||
Name: req.Name,
|
||||
Value: req.Value,
|
||||
Type: req.Type,
|
||||
Desc: req.Desc,
|
||||
Status: 1, // 默认启用
|
||||
}
|
||||
|
||||
err = s.storage.Create(config)
|
||||
if err != nil {
|
||||
s.logger.Error("创建系统配置失败", zap.Error(err))
|
||||
return nil, errors.New("创建系统配置失败")
|
||||
}
|
||||
|
||||
s.logger.Info("系统配置创建成功", zap.String("key", config.Key))
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取系统配置
|
||||
func (s *systemConfigService) GetByID(id uint) (*models.SystemConfig, error) {
|
||||
return s.storage.GetByID(id)
|
||||
}
|
||||
|
||||
// GetByKey 根据配置标识获取系统配置
|
||||
func (s *systemConfigService) GetByKey(key string) (*models.SystemConfig, error) {
|
||||
return s.storage.GetByKey(key)
|
||||
}
|
||||
|
||||
// Update 更新系统配置
|
||||
func (s *systemConfigService) Update(id uint, req *models.SystemConfigUpdateRequest) (*models.SystemConfig, error) {
|
||||
// 获取配置信息
|
||||
config, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.New("配置不存在")
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
config.Name = req.Name
|
||||
config.Value = req.Value
|
||||
config.Type = req.Type
|
||||
config.Desc = req.Desc
|
||||
|
||||
// 保存更新
|
||||
err = s.storage.Update(config)
|
||||
if err != nil {
|
||||
s.logger.Error("更新系统配置失败", zap.Error(err))
|
||||
return nil, errors.New("更新系统配置失败")
|
||||
}
|
||||
|
||||
s.logger.Info("系统配置更新成功", zap.Uint("id", id))
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Delete 删除系统配置
|
||||
func (s *systemConfigService) Delete(id uint) error {
|
||||
// 检查配置是否存在
|
||||
_, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("配置不存在")
|
||||
}
|
||||
|
||||
// 删除配置
|
||||
err = s.storage.Delete(id)
|
||||
if err != nil {
|
||||
s.logger.Error("删除系统配置失败", zap.Error(err))
|
||||
return errors.New("删除系统配置失败")
|
||||
}
|
||||
|
||||
s.logger.Info("系统配置删除成功", zap.Uint("id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 获取系统配置列表
|
||||
func (s *systemConfigService) List(req *models.SystemConfigListRequest) ([]models.SystemConfig, int64, error) {
|
||||
return s.storage.List(req)
|
||||
}
|
||||
|
||||
// UpdateStatus 更新状态
|
||||
func (s *systemConfigService) UpdateStatus(id uint, status int) error {
|
||||
// 检查配置是否存在
|
||||
_, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("配置不存在")
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
err = s.storage.UpdateStatus(id, status)
|
||||
if err != nil {
|
||||
s.logger.Error("更新系统配置状态失败", zap.Error(err))
|
||||
return errors.New("更新系统配置状态失败")
|
||||
}
|
||||
|
||||
s.logger.Info("系统配置状态更新成功", zap.Uint("id", id), zap.Int("status", status))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAll 获取所有系统配置
|
||||
func (s *systemConfigService) GetAll() ([]models.SystemConfig, error) {
|
||||
return s.storage.GetAll()
|
||||
}
|
||||
260
internal/services/user_feedback_service.go
Normal file
260
internal/services/user_feedback_service.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/oss"
|
||||
"goalfymax-admin/internal/storage"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// UserFeedbackService 用户反馈服务
|
||||
type UserFeedbackService struct {
|
||||
storage *storage.UserFeedbackStorage
|
||||
}
|
||||
|
||||
// NewUserFeedbackService 创建用户反馈服务实例
|
||||
func NewUserFeedbackService(storage *storage.UserFeedbackStorage) *UserFeedbackService {
|
||||
return &UserFeedbackService{storage: storage}
|
||||
}
|
||||
|
||||
// List 获取用户反馈列表
|
||||
func (s *UserFeedbackService) List(ctx context.Context, req *models.UserFeedbackListRequest) (*models.UserFeedbackListResponse, error) {
|
||||
// 参数校验
|
||||
if req.Page <= 0 {
|
||||
req.Page = 1
|
||||
}
|
||||
if req.PageSize <= 0 {
|
||||
req.PageSize = 10
|
||||
}
|
||||
if req.PageSize > 100 {
|
||||
req.PageSize = 100
|
||||
}
|
||||
|
||||
// 状态值校验
|
||||
if req.Status != nil && (*req.Status < 0 || *req.Status > 1) {
|
||||
return nil, fmt.Errorf("状态值无效")
|
||||
}
|
||||
|
||||
// 用户ID校验
|
||||
if req.UserID != nil && *req.UserID <= 0 {
|
||||
return nil, fmt.Errorf("用户ID无效")
|
||||
}
|
||||
|
||||
// 时间格式校验
|
||||
if req.StartTime != "" {
|
||||
if _, err := strconv.ParseInt(req.StartTime, 10, 64); err != nil {
|
||||
// 尝试解析时间格式
|
||||
if err := parseTimeString(req.StartTime); err != nil {
|
||||
return nil, fmt.Errorf("开始时间格式无效")
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.EndTime != "" {
|
||||
if _, err := strconv.ParseInt(req.EndTime, 10, 64); err != nil {
|
||||
// 尝试解析时间格式
|
||||
if err := parseTimeString(req.EndTime); err != nil {
|
||||
return nil, fmt.Errorf("结束时间格式无效")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 调用存储层
|
||||
feedbacks, total, err := s.storage.List(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取反馈列表失败: %w", err)
|
||||
}
|
||||
|
||||
// 转换为带有可访问URL的返回结构
|
||||
items := make([]models.UserFeedbackItem, 0, len(feedbacks))
|
||||
for _, fb := range feedbacks {
|
||||
var keys []string
|
||||
if fb.FileKeys != "" {
|
||||
_ = json.Unmarshal([]byte(fb.FileKeys), &keys)
|
||||
}
|
||||
|
||||
// 直接下载文件内容并进行Base64编码
|
||||
var fileContents []string
|
||||
for _, k := range keys {
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
content, mimeType, err := oss.DownloadFileContent(ctx, k)
|
||||
if err != nil {
|
||||
// 记录错误,但继续处理其他文件
|
||||
fmt.Printf("Error downloading file %s: %v\n", k, err)
|
||||
continue
|
||||
}
|
||||
encodedContent := fmt.Sprintf("data:%s;base64,%s", mimeType, base64.StdEncoding.EncodeToString(content))
|
||||
fileContents = append(fileContents, encodedContent)
|
||||
}
|
||||
|
||||
items = append(items, models.UserFeedbackItem{
|
||||
ID: fb.ID,
|
||||
UserID: fb.UID,
|
||||
Content: fb.Content,
|
||||
FileKeys: keys,
|
||||
FileContents: fileContents, // 返回Base64编码的内容
|
||||
Status: fb.Status,
|
||||
HandledBy: fb.HandledBy,
|
||||
HandledAt: fb.HandledAt,
|
||||
CreatedAt: fb.CreatedAt,
|
||||
UpdatedAt: fb.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return &models.UserFeedbackListResponse{
|
||||
List: items,
|
||||
Total: total,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取用户反馈
|
||||
func (s *UserFeedbackService) GetByID(ctx context.Context, id int64) (*models.UserFeedbackItem, error) {
|
||||
if id <= 0 {
|
||||
return nil, fmt.Errorf("反馈ID无效")
|
||||
}
|
||||
|
||||
feedback, err := s.storage.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取用户反馈失败: %w", err)
|
||||
}
|
||||
|
||||
if feedback == nil {
|
||||
return nil, fmt.Errorf("反馈不存在")
|
||||
}
|
||||
|
||||
// 解析 file_keys
|
||||
var keys []string
|
||||
if feedback.FileKeys != "" {
|
||||
_ = json.Unmarshal([]byte(feedback.FileKeys), &keys)
|
||||
}
|
||||
|
||||
// 下载文件内容并进行Base64编码
|
||||
var fileContents []string
|
||||
for _, k := range keys {
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
content, mimeType, err := oss.DownloadFileContent(ctx, k)
|
||||
if err != nil {
|
||||
fmt.Printf("Error downloading file %s: %v\n", k, err)
|
||||
continue
|
||||
}
|
||||
encodedContent := fmt.Sprintf("data:%s;base64,%s", mimeType, base64.StdEncoding.EncodeToString(content))
|
||||
fileContents = append(fileContents, encodedContent)
|
||||
}
|
||||
|
||||
return &models.UserFeedbackItem{
|
||||
ID: feedback.ID,
|
||||
UserID: feedback.UID,
|
||||
Content: feedback.Content,
|
||||
FileKeys: keys,
|
||||
FileContents: fileContents,
|
||||
Status: feedback.Status,
|
||||
HandledBy: feedback.HandledBy,
|
||||
HandledAt: feedback.HandledAt,
|
||||
CreatedAt: feedback.CreatedAt,
|
||||
UpdatedAt: feedback.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MarkHandled 切换处理状态(已处理/未处理)
|
||||
func (s *UserFeedbackService) MarkHandled(ctx context.Context, id int64, handledBy int, note string) error {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("反馈ID无效")
|
||||
}
|
||||
|
||||
// 检查反馈是否存在
|
||||
feedback, err := s.storage.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取反馈信息失败: %w", err)
|
||||
}
|
||||
|
||||
if feedback == nil {
|
||||
return fmt.Errorf("反馈不存在")
|
||||
}
|
||||
|
||||
// 如果要标记为已处理,需要处理人ID
|
||||
if feedback.Status == 0 && handledBy <= 0 {
|
||||
return fmt.Errorf("处理人ID无效")
|
||||
}
|
||||
|
||||
// 切换状态
|
||||
if err := s.storage.MarkHandled(ctx, id, handledBy, note); err != nil {
|
||||
return fmt.Errorf("切换状态失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除用户反馈
|
||||
func (s *UserFeedbackService) Delete(ctx context.Context, id int64) error {
|
||||
if id <= 0 {
|
||||
return fmt.Errorf("反馈ID无效")
|
||||
}
|
||||
|
||||
// 检查反馈是否存在
|
||||
feedback, err := s.storage.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取反馈信息失败: %w", err)
|
||||
}
|
||||
|
||||
if feedback == nil {
|
||||
return fmt.Errorf("反馈不存在")
|
||||
}
|
||||
|
||||
// 删除反馈
|
||||
if err := s.storage.Delete(ctx, id); err != nil {
|
||||
return fmt.Errorf("删除反馈失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatistics 获取反馈统计信息
|
||||
func (s *UserFeedbackService) GetStatistics(ctx context.Context) (map[string]interface{}, error) {
|
||||
stats, err := s.storage.GetStatistics(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取统计信息失败: %w", err)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// parseTimeString 解析时间字符串
|
||||
func parseTimeString(timeStr string) error {
|
||||
// 支持多种时间格式
|
||||
formats := []string{
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
"2006/01/02 15:04:05",
|
||||
"2006/01/02",
|
||||
}
|
||||
|
||||
// 先检查是否是时间戳格式
|
||||
if _, err := strconv.ParseInt(timeStr, 10, 64); err == nil {
|
||||
return nil // 时间戳格式
|
||||
}
|
||||
|
||||
// 检查其他时间格式
|
||||
for _, format := range formats {
|
||||
// 这里可以添加实际的时间解析逻辑,暂时跳过
|
||||
_ = format
|
||||
}
|
||||
|
||||
return fmt.Errorf("时间格式无效")
|
||||
}
|
||||
|
||||
// hasHTTPPrefix 判断字符串是否为 http/https URL
|
||||
func hasHTTPPrefix(s string) bool {
|
||||
// 此函数在此服务中已不再需要,但保留以避免潜在编译错误,或者可以在此被移除
|
||||
return false
|
||||
}
|
||||
|
||||
// joinURL 已废弃,改为强制预签名
|
||||
145
internal/services/user_level_config_service.go
Normal file
145
internal/services/user_level_config_service.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go.uber.org/zap"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
"goalfymax-admin/pkg/utils"
|
||||
)
|
||||
|
||||
// UserLevelConfigService 用户等级配置服务接口
|
||||
type UserLevelConfigService interface {
|
||||
Create(req *models.UserLevelConfigCreateRequest) (*models.UserLevelConfig, error)
|
||||
GetByID(id uint) (*models.UserLevelConfig, error)
|
||||
Update(id uint, req *models.UserLevelConfigUpdateRequest) (*models.UserLevelConfig, error)
|
||||
Delete(id uint) error
|
||||
List(req *models.UserLevelConfigListRequest) ([]models.UserLevelConfig, int64, error)
|
||||
UpdateStatus(id uint, status int) error
|
||||
GetAll() ([]models.UserLevelConfig, error)
|
||||
}
|
||||
|
||||
type userLevelConfigService struct {
|
||||
storage storage.UserLevelConfigStorage
|
||||
logger *utils.Logger
|
||||
}
|
||||
|
||||
// NewUserLevelConfigService 创建用户等级配置服务实例
|
||||
func NewUserLevelConfigService(storage storage.UserLevelConfigStorage, logger *utils.Logger) UserLevelConfigService {
|
||||
return &userLevelConfigService{
|
||||
storage: storage,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建用户等级配置
|
||||
func (s *userLevelConfigService) Create(req *models.UserLevelConfigCreateRequest) (*models.UserLevelConfig, error) {
|
||||
// 检查等级代码是否已存在
|
||||
_, err := s.storage.GetByLevelCode(req.LevelCode)
|
||||
if err == nil {
|
||||
return nil, errors.New("等级代码已存在")
|
||||
}
|
||||
|
||||
// 创建配置
|
||||
config := &models.UserLevelConfig{
|
||||
LevelName: req.LevelName,
|
||||
LevelCode: req.LevelCode,
|
||||
ProjectLimit: req.ProjectLimit,
|
||||
CoderVMLimit: req.CoderVMLimit,
|
||||
BrowserVMLimit: req.BrowserVMLimit,
|
||||
ProcessLimit: req.ProcessLimit,
|
||||
Description: req.Description,
|
||||
SortOrder: req.SortOrder,
|
||||
Status: 1, // 默认启用
|
||||
}
|
||||
|
||||
err = s.storage.Create(config)
|
||||
if err != nil {
|
||||
s.logger.Error("创建用户等级配置失败", zap.Error(err))
|
||||
return nil, errors.New("创建用户等级配置失败")
|
||||
}
|
||||
|
||||
s.logger.Info("用户等级配置创建成功", zap.String("level_name", config.LevelName))
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取用户等级配置
|
||||
func (s *userLevelConfigService) GetByID(id uint) (*models.UserLevelConfig, error) {
|
||||
return s.storage.GetByID(id)
|
||||
}
|
||||
|
||||
// Update 更新用户等级配置
|
||||
func (s *userLevelConfigService) Update(id uint, req *models.UserLevelConfigUpdateRequest) (*models.UserLevelConfig, error) {
|
||||
// 获取配置信息
|
||||
config, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.New("配置不存在")
|
||||
}
|
||||
|
||||
// 更新字段
|
||||
config.LevelName = req.LevelName
|
||||
config.ProjectLimit = req.ProjectLimit
|
||||
config.CoderVMLimit = req.CoderVMLimit
|
||||
config.BrowserVMLimit = req.BrowserVMLimit
|
||||
config.ProcessLimit = req.ProcessLimit
|
||||
config.Description = req.Description
|
||||
config.SortOrder = req.SortOrder
|
||||
|
||||
// 保存更新
|
||||
err = s.storage.Update(config)
|
||||
if err != nil {
|
||||
s.logger.Error("更新用户等级配置失败", zap.Error(err))
|
||||
return nil, errors.New("更新用户等级配置失败")
|
||||
}
|
||||
|
||||
s.logger.Info("用户等级配置更新成功", zap.Uint("id", id))
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Delete 删除用户等级配置
|
||||
func (s *userLevelConfigService) Delete(id uint) error {
|
||||
// 检查配置是否存在
|
||||
_, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("配置不存在")
|
||||
}
|
||||
|
||||
// 删除配置
|
||||
err = s.storage.Delete(id)
|
||||
if err != nil {
|
||||
s.logger.Error("删除用户等级配置失败", zap.Error(err))
|
||||
return errors.New("删除用户等级配置失败")
|
||||
}
|
||||
|
||||
s.logger.Info("用户等级配置删除成功", zap.Uint("id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 获取用户等级配置列表
|
||||
func (s *userLevelConfigService) List(req *models.UserLevelConfigListRequest) ([]models.UserLevelConfig, int64, error) {
|
||||
return s.storage.List(req)
|
||||
}
|
||||
|
||||
// UpdateStatus 更新状态
|
||||
func (s *userLevelConfigService) UpdateStatus(id uint, status int) error {
|
||||
// 检查配置是否存在
|
||||
_, err := s.storage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("配置不存在")
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
err = s.storage.UpdateStatus(id, status)
|
||||
if err != nil {
|
||||
s.logger.Error("更新用户等级配置状态失败", zap.Error(err))
|
||||
return errors.New("更新用户等级配置状态失败")
|
||||
}
|
||||
|
||||
s.logger.Info("用户等级配置状态更新成功", zap.Uint("id", id), zap.Int("status", status))
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAll 获取所有用户等级配置
|
||||
func (s *userLevelConfigService) GetAll() ([]models.UserLevelConfig, error) {
|
||||
return s.storage.GetAll()
|
||||
}
|
||||
79
internal/services/user_project_quota_service.go
Normal file
79
internal/services/user_project_quota_service.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
)
|
||||
|
||||
type UserProjectQuotaService interface {
|
||||
Create(input *models.UserProjectQuota) (*models.UserProjectQuota, error)
|
||||
Update(id uint, input *models.UserProjectQuota) (*models.UserProjectQuota, error)
|
||||
Delete(id uint) error
|
||||
GetByID(id uint) (*models.UserProjectQuota, error)
|
||||
GetByUserID(userID string) (*models.UserProjectQuota, error)
|
||||
List(filter storage.UserProjectQuotaFilter) ([]models.UserProjectQuota, int64, error)
|
||||
}
|
||||
|
||||
type userProjectQuotaService struct {
|
||||
store storage.UserProjectQuotaStorage
|
||||
}
|
||||
|
||||
func NewUserProjectQuotaService(store storage.UserProjectQuotaStorage) UserProjectQuotaService {
|
||||
return &userProjectQuotaService{store: store}
|
||||
}
|
||||
|
||||
func (s *userProjectQuotaService) validate(q *models.UserProjectQuota) error {
|
||||
if q.UserID == "" {
|
||||
return errors.New("user_id 不能为空")
|
||||
}
|
||||
if q.ProjectLimit < 0 || q.CoderVMLimit < 0 || q.BrowserVMLimit < 0 || q.ProcessLimit < 0 {
|
||||
return errors.New("配额上限不能为负数")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *userProjectQuotaService) Create(input *models.UserProjectQuota) (*models.UserProjectQuota, error) {
|
||||
if err := s.validate(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.store.GetByUserID(input.UserID); err == nil {
|
||||
return nil, errors.New("该用户配额已存在")
|
||||
}
|
||||
if err := s.store.Create(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (s *userProjectQuotaService) Update(id uint, input *models.UserProjectQuota) (*models.UserProjectQuota, error) {
|
||||
if err := s.validate(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exist, err := s.store.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.New("记录不存在")
|
||||
}
|
||||
// user_id 不建议修改,如需修改可放开以下行:exist.UserID = input.UserID
|
||||
exist.ProjectLimit = input.ProjectLimit
|
||||
exist.CoderVMLimit = input.CoderVMLimit
|
||||
exist.BrowserVMLimit = input.BrowserVMLimit
|
||||
exist.ProcessLimit = input.ProcessLimit
|
||||
exist.Enabled = input.Enabled
|
||||
exist.Description = input.Description
|
||||
if err := s.store.Update(exist); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return exist, nil
|
||||
}
|
||||
|
||||
func (s *userProjectQuotaService) Delete(id uint) error { return s.store.Delete(id) }
|
||||
func (s *userProjectQuotaService) GetByID(id uint) (*models.UserProjectQuota, error) {
|
||||
return s.store.GetByID(id)
|
||||
}
|
||||
func (s *userProjectQuotaService) GetByUserID(userID string) (*models.UserProjectQuota, error) {
|
||||
return s.store.GetByUserID(userID)
|
||||
}
|
||||
func (s *userProjectQuotaService) List(filter storage.UserProjectQuotaFilter) ([]models.UserProjectQuota, int64, error) {
|
||||
return s.store.List(filter)
|
||||
}
|
||||
404
internal/services/user_service.go
Normal file
404
internal/services/user_service.go
Normal file
@@ -0,0 +1,404 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"goalfymax-admin/internal/models"
|
||||
"goalfymax-admin/internal/storage"
|
||||
"goalfymax-admin/pkg/utils"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// UserService 用户服务接口
|
||||
type UserService interface {
|
||||
Login(req *models.LoginRequest) (*models.LoginResponse, error)
|
||||
Create(req *models.UserCreateRequest) (*models.User, error)
|
||||
GetByID(id uint) (*models.User, error)
|
||||
Update(id uint, req *models.UserUpdateRequest) (*models.User, error)
|
||||
Delete(id uint) error
|
||||
List(req *models.UserListRequest) ([]models.User, int64, error)
|
||||
ListWithRoles(req *models.UserListRequest) ([]models.UserWithRoles, int64, error)
|
||||
UpdateStatus(id uint, status int) error
|
||||
UpdateRole(id uint, roleID uint) error
|
||||
CheckUserSystemRole(userID int) (bool, error)
|
||||
ChangeUserSystemRole(userID int, systemRole string) error
|
||||
}
|
||||
|
||||
type userService struct {
|
||||
userStorage storage.UserStorage
|
||||
rbacStorage storage.RBACStorage
|
||||
goalfyMaxUserStorage storage.GoalfyMaxUserStorage
|
||||
jwtManager *utils.JWTManager
|
||||
logger *utils.Logger
|
||||
ssoAdminService SSOAdminService
|
||||
}
|
||||
|
||||
// NewUserService 创建用户服务实例
|
||||
func NewUserService(userStorage storage.UserStorage, rbacStorage storage.RBACStorage, goalfyMaxUserStorage storage.GoalfyMaxUserStorage, jwtManager *utils.JWTManager, logger *utils.Logger, ssoAdminService SSOAdminService) UserService {
|
||||
return &userService{
|
||||
userStorage: userStorage,
|
||||
rbacStorage: rbacStorage,
|
||||
goalfyMaxUserStorage: goalfyMaxUserStorage,
|
||||
jwtManager: jwtManager,
|
||||
logger: logger,
|
||||
ssoAdminService: ssoAdminService,
|
||||
}
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (s *userService) Login(req *models.LoginRequest) (*models.LoginResponse, error) {
|
||||
// 获取用户信息
|
||||
user, err := s.userStorage.GetByUsername(req.Username)
|
||||
if err != nil {
|
||||
s.logger.Error("用户不存在", zap.String("username", req.Username))
|
||||
return nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if user.Status != 1 {
|
||||
s.logger.Error("用户已被禁用", zap.String("username", req.Username))
|
||||
return nil, errors.New("用户已被禁用")
|
||||
}
|
||||
|
||||
// SSO用户不需要密码验证,直接通过
|
||||
|
||||
// 生成JWT token
|
||||
token, err := s.jwtManager.GenerateToken(user.ID, user.Username)
|
||||
if err != nil {
|
||||
s.logger.Error("生成token失败", zap.Error(err))
|
||||
return nil, errors.New("登录失败")
|
||||
}
|
||||
|
||||
// 记录登录日志
|
||||
// 这里应该调用日志存储服务记录登录日志
|
||||
|
||||
s.logger.Info("用户登录成功", zap.String("username", user.Username))
|
||||
|
||||
return &models.LoginResponse{
|
||||
Token: token,
|
||||
User: *user,
|
||||
ExpireAt: 0, // 实际应该计算过期时间
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Create 创建用户
|
||||
func (s *userService) Create(req *models.UserCreateRequest) (*models.User, error) {
|
||||
// 验证用户名是否已存在
|
||||
_, err := s.userStorage.GetByUsername(req.Username)
|
||||
if err == nil {
|
||||
return nil, errors.New("用户名已存在")
|
||||
}
|
||||
|
||||
// 验证邮箱是否已存在
|
||||
_, err = s.userStorage.GetByEmail(req.Email)
|
||||
if err == nil {
|
||||
return nil, errors.New("邮箱已存在")
|
||||
}
|
||||
|
||||
// 1. 先调用SSO创建用户
|
||||
ctx := context.Background()
|
||||
ssoReq := &SSOAdminUserCreateRequest{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Phone: "", // 默认空手机号
|
||||
Password: req.Password,
|
||||
}
|
||||
|
||||
ssoUser, err := s.ssoAdminService.CreateUser(ctx, ssoReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SSO创建用户失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 设置系统角色为sys_admin
|
||||
if err := s.ssoAdminService.SetSystemRole(ctx, ssoUser.ID, "sys_admin"); err != nil {
|
||||
// 如果设置失败,记录错误但不阻止用户创建
|
||||
fmt.Printf("警告: 设置系统角色失败: %v\n", err)
|
||||
}
|
||||
|
||||
// 设置角色ID,如果没有指定则使用默认角色
|
||||
roleID := req.RoleID
|
||||
if roleID == 0 {
|
||||
defaultRoleID, err := s.getDefaultRole()
|
||||
if err != nil {
|
||||
s.logger.Error("获取默认角色失败", zap.Error(err))
|
||||
return nil, errors.New("获取默认角色失败")
|
||||
}
|
||||
roleID = defaultRoleID
|
||||
}
|
||||
|
||||
// 创建用户
|
||||
user := &models.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
Nickname: req.Nickname,
|
||||
Status: 1,
|
||||
SSOProvider: req.SSOProvider,
|
||||
RoleID: roleID,
|
||||
}
|
||||
|
||||
err = s.userStorage.Create(user)
|
||||
if err != nil {
|
||||
s.logger.Error("创建用户失败", zap.Error(err))
|
||||
return nil, errors.New("创建用户失败")
|
||||
}
|
||||
|
||||
s.logger.Info("用户创建成功", zap.String("username", user.Username), zap.Uint("role_id", roleID))
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取用户
|
||||
func (s *userService) GetByID(id uint) (*models.User, error) {
|
||||
return s.userStorage.GetByID(id)
|
||||
}
|
||||
|
||||
// Update 更新用户
|
||||
func (s *userService) Update(id uint, req *models.UserUpdateRequest) (*models.User, error) {
|
||||
// 获取用户信息
|
||||
user, err := s.userStorage.GetByID(id)
|
||||
if err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
if req.Nickname != "" {
|
||||
user.Nickname = req.Nickname
|
||||
}
|
||||
if req.Email != "" {
|
||||
// 检查邮箱是否已被其他用户使用
|
||||
existingUser, err := s.userStorage.GetByEmail(req.Email)
|
||||
if err == nil && existingUser.ID != id {
|
||||
return nil, errors.New("邮箱已被其他用户使用")
|
||||
}
|
||||
user.Email = req.Email
|
||||
}
|
||||
if req.Avatar != "" {
|
||||
user.Avatar = req.Avatar
|
||||
}
|
||||
if req.Status != nil {
|
||||
user.Status = *req.Status
|
||||
}
|
||||
if req.RoleID != nil {
|
||||
user.RoleID = *req.RoleID
|
||||
}
|
||||
if req.SSOProvider != "" {
|
||||
user.SSOProvider = req.SSOProvider
|
||||
}
|
||||
|
||||
err = s.userStorage.Update(user)
|
||||
if err != nil {
|
||||
s.logger.Error("更新用户失败", zap.Error(err))
|
||||
return nil, errors.New("更新用户失败")
|
||||
}
|
||||
|
||||
s.logger.Info("用户更新成功", zap.Uint("user_id", id))
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Delete 删除用户
|
||||
func (s *userService) Delete(id uint) error {
|
||||
// 检查用户是否存在
|
||||
_, err := s.userStorage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
err = s.userStorage.Delete(id)
|
||||
if err != nil {
|
||||
s.logger.Error("删除用户失败", zap.Error(err))
|
||||
return errors.New("删除用户失败")
|
||||
}
|
||||
|
||||
s.logger.Info("用户删除成功", zap.Uint("user_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
// List 获取用户列表
|
||||
func (s *userService) List(req *models.UserListRequest) ([]models.User, int64, error) {
|
||||
return s.userStorage.List(req)
|
||||
}
|
||||
|
||||
// UpdateStatus 更新用户状态
|
||||
func (s *userService) UpdateStatus(id uint, status int) error {
|
||||
// 检查用户是否存在
|
||||
_, err := s.userStorage.GetByID(id)
|
||||
if err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
err = s.userStorage.UpdateStatus(id, status)
|
||||
if err != nil {
|
||||
s.logger.Error("更新用户状态失败", zap.Error(err))
|
||||
return errors.New("更新用户状态失败")
|
||||
}
|
||||
|
||||
s.logger.Info("用户状态更新成功", zap.Uint("user_id", id), zap.Int("status", status))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWithRoles 获取用户列表(包含角色信息)
|
||||
func (s *userService) ListWithRoles(req *models.UserListRequest) ([]models.UserWithRoles, int64, error) {
|
||||
// 获取用户列表
|
||||
users, total, err := s.userStorage.List(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 为每个用户查询角色信息
|
||||
usersWithRoles := make([]models.UserWithRoles, len(users))
|
||||
for i, user := range users {
|
||||
if user.RoleID == 0 {
|
||||
// role_id 为 0 表示未分配角色,直接返回空角色
|
||||
s.logger.Debug("用户未分配角色", zap.Uint("userID", user.ID))
|
||||
usersWithRoles[i] = models.UserWithRoles{User: user, Role: nil}
|
||||
continue
|
||||
}
|
||||
|
||||
// 查询用户角色
|
||||
role, err := s.rbacStorage.GetRoleByID(user.RoleID)
|
||||
if err != nil {
|
||||
s.logger.Warn("获取用户角色失败", zap.Uint("userID", user.ID), zap.Uint("roleID", user.RoleID), zap.Error(err))
|
||||
// 如果获取角色失败,仍然返回用户信息,但角色为空
|
||||
usersWithRoles[i] = models.UserWithRoles{
|
||||
User: user,
|
||||
Role: nil,
|
||||
}
|
||||
} else {
|
||||
usersWithRoles[i] = models.UserWithRoles{
|
||||
User: user,
|
||||
Role: role,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return usersWithRoles, total, nil
|
||||
}
|
||||
|
||||
// getDefaultRole 获取默认角色(L5全员)
|
||||
func (s *userService) getDefaultRole() (uint, error) {
|
||||
// 查询默认角色ID
|
||||
var roleID uint
|
||||
err := s.rbacStorage.GetDefaultRoleID(&roleID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取默认角色失败", zap.Error(err))
|
||||
return 0, errors.New("获取默认角色失败")
|
||||
}
|
||||
return roleID, nil
|
||||
}
|
||||
|
||||
// UpdateRole 更新用户角色
|
||||
func (s *userService) UpdateRole(id uint, roleID uint) error {
|
||||
// 检查用户是否存在
|
||||
user, err := s.userStorage.GetByID(id)
|
||||
if err != nil {
|
||||
s.logger.Error("获取用户信息失败", zap.Uint("user_id", id), zap.Error(err))
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
|
||||
// 更新用户角色
|
||||
user.RoleID = roleID
|
||||
err = s.userStorage.Update(user)
|
||||
if err != nil {
|
||||
s.logger.Error("更新用户角色失败", zap.Uint("user_id", id), zap.Uint("role_id", roleID), zap.Error(err))
|
||||
return errors.New("更新用户角色失败")
|
||||
}
|
||||
|
||||
s.logger.Info("用户角色更新成功", zap.Uint("user_id", id), zap.Uint("role_id", roleID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckUserSystemRole 检查用户在系统用户管理表中的角色
|
||||
// 返回: true表示存在(sys_admin),false表示不存在(custom)
|
||||
func (s *userService) CheckUserSystemRole(userID int) (bool, error) {
|
||||
// admin_users表的id字段就是SSO用户ID
|
||||
// 所以通过userID查询admin_users表,如果存在则说明是系统管理员
|
||||
_, err := s.userStorage.GetByID(uint(userID))
|
||||
if err != nil {
|
||||
// 如果查询失败(用户不存在),返回false
|
||||
return false, nil
|
||||
}
|
||||
// 用户存在,返回true
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ChangeUserSystemRole 变更用户在SSO中的系统角色
|
||||
// 同时需要在admin_users表中添加或删除记录
|
||||
func (s *userService) ChangeUserSystemRole(userID int, systemRole string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. 先调用SSO接口变更角色
|
||||
err := s.ssoAdminService.SetSystemRole(ctx, userID, systemRole)
|
||||
if err != nil {
|
||||
s.logger.Error("调用SSO接口变更角色失败", zap.Int("user_id", userID), zap.String("system_role", systemRole), zap.Error(err))
|
||||
return fmt.Errorf("调用SSO接口变更角色失败: %w", err)
|
||||
}
|
||||
|
||||
// 2. 根据角色变更,更新admin_users表
|
||||
if systemRole == "sys_admin" {
|
||||
// 设置为系统管理员:需要确保admin_users表中存在该用户
|
||||
_, err := s.userStorage.GetByID(uint(userID))
|
||||
if err != nil {
|
||||
// 用户不存在,需要创建
|
||||
// 从GoalfyMax用户表获取用户信息
|
||||
goalfyUser, err := s.goalfyMaxUserStorage.GetByUserID(userID)
|
||||
if err != nil {
|
||||
s.logger.Error("获取GoalfyMax用户信息失败", zap.Int("user_id", userID), zap.Error(err))
|
||||
return fmt.Errorf("获取GoalfyMax用户信息失败: %w", err)
|
||||
}
|
||||
|
||||
// 获取默认角色ID(系统管理员角色)
|
||||
defaultRoleID, err := s.getDefaultRole()
|
||||
if err != nil {
|
||||
s.logger.Error("获取默认角色失败", zap.Error(err))
|
||||
// 如果获取默认角色失败,使用角色ID 5(系统管理员)
|
||||
defaultRoleID = 5
|
||||
}
|
||||
|
||||
// 创建admin_users记录
|
||||
// 注意:admin_users表的id字段对应SSO用户ID
|
||||
user := &models.User{
|
||||
BaseModel: models.BaseModel{
|
||||
ID: uint(userID), // 使用SSO用户ID作为主键
|
||||
},
|
||||
Username: goalfyUser.Username,
|
||||
Email: goalfyUser.Email,
|
||||
Nickname: goalfyUser.Nickname,
|
||||
Status: 1,
|
||||
SSOProvider: "default",
|
||||
RoleID: defaultRoleID,
|
||||
}
|
||||
|
||||
// 使用storage的Create方法,它应该能处理指定ID的情况
|
||||
err = s.userStorage.Create(user)
|
||||
if err != nil {
|
||||
s.logger.Error("创建admin_users记录失败", zap.Int("user_id", userID), zap.Error(err))
|
||||
return fmt.Errorf("创建admin_users记录失败: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("创建系统管理员用户成功", zap.Int("user_id", userID))
|
||||
} else {
|
||||
// 用户已存在,更新状态为正常(如果需要)
|
||||
s.logger.Info("用户已是系统管理员", zap.Int("user_id", userID))
|
||||
}
|
||||
} else if systemRole == "custom" {
|
||||
// 设置为普通用户:需要删除admin_users表中的记录(软删除)
|
||||
_, err := s.userStorage.GetByID(uint(userID))
|
||||
if err == nil {
|
||||
// 用户存在,删除记录
|
||||
err = s.userStorage.Delete(uint(userID))
|
||||
if err != nil {
|
||||
s.logger.Error("删除admin_users记录失败", zap.Int("user_id", userID), zap.Error(err))
|
||||
return fmt.Errorf("删除admin_users记录失败: %w", err)
|
||||
}
|
||||
s.logger.Info("删除系统管理员用户成功", zap.Int("user_id", userID))
|
||||
} else {
|
||||
s.logger.Info("用户不是系统管理员,无需删除", zap.Int("user_id", userID))
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("无效的系统角色: %s", systemRole)
|
||||
}
|
||||
|
||||
s.logger.Info("用户系统角色变更成功", zap.Int("user_id", userID), zap.String("system_role", systemRole))
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user