feat: 实现前端卡密管理界面
- 卡密列表展示与分页功能 - 单个/批量创建卡密 - 卡密删除与批量删除 - 卡密导出功能 (file-saver) - 启用/禁用状态切换 - 状态判断 (有效/已使用/已失效) - Toast 通知系统 (vue-sonner) - 登录页面错误提示优化 - 后端登录错误消息中文化
This commit is contained in:
138
backend/internal/handler/auth_handler.go
Normal file
138
backend/internal/handler/auth_handler.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"gpt-manager-go/internal/auth"
|
||||
"gpt-manager-go/internal/repository"
|
||||
)
|
||||
|
||||
// AuthHandler 认证处理器
|
||||
type AuthHandler struct {
|
||||
adminRepo *repository.AdminRepository
|
||||
}
|
||||
|
||||
// NewAuthHandler 创建认证处理器
|
||||
func NewAuthHandler(adminRepo *repository.AdminRepository) *AuthHandler {
|
||||
return &AuthHandler{adminRepo: adminRepo}
|
||||
}
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// LoginResponse 登录响应
|
||||
type LoginResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Token string `json:"token,omitempty"`
|
||||
User *UserInfo `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// UserInfo 用户信息
|
||||
type UserInfo struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
IsSuperAdmin bool `json:"is_super_admin"`
|
||||
}
|
||||
|
||||
// Login 登录接口
|
||||
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, LoginResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, LoginResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if req.Username == "" || req.Password == "" {
|
||||
respondJSON(w, http.StatusBadRequest, LoginResponse{
|
||||
Success: false,
|
||||
Message: "Username and password are required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 查找用户
|
||||
admin, err := h.adminRepo.FindByUsername(req.Username)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, LoginResponse{
|
||||
Success: false,
|
||||
Message: "Internal server error",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if admin == nil {
|
||||
respondJSON(w, http.StatusUnauthorized, LoginResponse{
|
||||
Success: false,
|
||||
Message: "用户名或密码错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查账号是否激活
|
||||
if !admin.IsActive {
|
||||
respondJSON(w, http.StatusForbidden, LoginResponse{
|
||||
Success: false,
|
||||
Message: "账号已被禁用",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if !auth.CheckPassword(req.Password, admin.PasswordHash) {
|
||||
respondJSON(w, http.StatusUnauthorized, LoginResponse{
|
||||
Success: false,
|
||||
Message: "用户名或密码错误",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 Token
|
||||
token, err := auth.GenerateToken(admin.ID, admin.Username, admin.IsSuperAdmin)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, LoginResponse{
|
||||
Success: false,
|
||||
Message: "Failed to generate token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新最后登录时间
|
||||
_ = h.adminRepo.UpdateLastLogin(admin.ID)
|
||||
|
||||
respondJSON(w, http.StatusOK, LoginResponse{
|
||||
Success: true,
|
||||
Message: "Login successful",
|
||||
Token: token,
|
||||
User: &UserInfo{
|
||||
ID: admin.ID,
|
||||
Username: admin.Username,
|
||||
Email: admin.Email,
|
||||
IsSuperAdmin: admin.IsSuperAdmin,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// respondJSON 返回 JSON 响应
|
||||
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
367
backend/internal/handler/card_key_handler.go
Normal file
367
backend/internal/handler/card_key_handler.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gpt-manager-go/internal/middleware"
|
||||
"gpt-manager-go/internal/models"
|
||||
"gpt-manager-go/internal/repository"
|
||||
)
|
||||
|
||||
// CardKeyHandler 卡密处理器
|
||||
type CardKeyHandler struct {
|
||||
repo *repository.CardKeyRepository
|
||||
}
|
||||
|
||||
// NewCardKeyHandler 创建处理器
|
||||
func NewCardKeyHandler(repo *repository.CardKeyRepository) *CardKeyHandler {
|
||||
return &CardKeyHandler{repo: repo}
|
||||
}
|
||||
|
||||
// CreateCardKeyRequest 创建卡密请求
|
||||
type CreateCardKeyRequest struct {
|
||||
ValidityDays int `json:"validity_days"` // 有效期天数,默认30
|
||||
MaxUses int `json:"max_uses"` // 最大使用次数,默认1
|
||||
}
|
||||
|
||||
// BatchCreateCardKeyRequest 批量创建卡密请求
|
||||
type BatchCreateCardKeyRequest struct {
|
||||
Count int `json:"count"` // 创建数量
|
||||
ValidityDays int `json:"validity_days"` // 有效期天数,默认30
|
||||
MaxUses int `json:"max_uses"` // 最大使用次数,默认1
|
||||
}
|
||||
|
||||
// CardKeyResponse 卡密响应
|
||||
type CardKeyResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data *models.CardKey `json:"data,omitempty"`
|
||||
Keys []*models.CardKey `json:"keys,omitempty"`
|
||||
Total int `json:"total,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"page_size,omitempty"`
|
||||
}
|
||||
|
||||
// Handle 处理卡密接口 (GET: 列表, POST: 创建)
|
||||
func (h *CardKeyHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
h.list(w, r)
|
||||
case http.MethodPost:
|
||||
h.create(w, r)
|
||||
default:
|
||||
respondJSON(w, http.StatusMethodNotAllowed, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// create 创建单个卡密
|
||||
func (h *CardKeyHandler) create(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req CreateCardKeyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
// 允许空请求体,使用默认值
|
||||
req = CreateCardKeyRequest{}
|
||||
}
|
||||
|
||||
// 设置默认值
|
||||
if req.ValidityDays <= 0 {
|
||||
req.ValidityDays = 30
|
||||
}
|
||||
if req.MaxUses <= 0 {
|
||||
req.MaxUses = 1
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
claims := middleware.GetUserFromContext(r.Context())
|
||||
|
||||
// 生成卡密
|
||||
cardKey := &models.CardKey{
|
||||
Key: generateCardKey(),
|
||||
MaxUses: req.MaxUses,
|
||||
UsedCount: 0,
|
||||
ValidityType: "custom",
|
||||
ExpiresAt: time.Now().AddDate(0, 0, req.ValidityDays),
|
||||
IsActive: true,
|
||||
CreatedByID: claims.UserID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := h.repo.Create(cardKey); err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Failed to create card key: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, CardKeyResponse{
|
||||
Success: true,
|
||||
Message: "Card key created successfully",
|
||||
Data: cardKey,
|
||||
})
|
||||
}
|
||||
|
||||
// BatchCreate 批量创建卡密 (POST /api/cardkeys/batch)
|
||||
func (h *CardKeyHandler) BatchCreate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req BatchCreateCardKeyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证和设置默认值
|
||||
if req.Count <= 0 {
|
||||
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Count must be greater than 0",
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.Count > 100 {
|
||||
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Count cannot exceed 100",
|
||||
})
|
||||
return
|
||||
}
|
||||
if req.ValidityDays <= 0 {
|
||||
req.ValidityDays = 30
|
||||
}
|
||||
if req.MaxUses <= 0 {
|
||||
req.MaxUses = 1
|
||||
}
|
||||
|
||||
// 获取当前用户
|
||||
claims := middleware.GetUserFromContext(r.Context())
|
||||
|
||||
// 批量创建
|
||||
var createdKeys []*models.CardKey
|
||||
expiresAt := time.Now().AddDate(0, 0, req.ValidityDays)
|
||||
|
||||
for i := 0; i < req.Count; i++ {
|
||||
cardKey := &models.CardKey{
|
||||
Key: generateCardKey(),
|
||||
MaxUses: req.MaxUses,
|
||||
UsedCount: 0,
|
||||
ValidityType: "custom",
|
||||
ExpiresAt: expiresAt,
|
||||
IsActive: true,
|
||||
CreatedByID: claims.UserID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := h.repo.Create(cardKey); err != nil {
|
||||
// 如果创建失败,返回已创建的卡密
|
||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Failed to create some card keys",
|
||||
Keys: createdKeys,
|
||||
})
|
||||
return
|
||||
}
|
||||
createdKeys = append(createdKeys, cardKey)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, CardKeyResponse{
|
||||
Success: true,
|
||||
Message: "Card keys created successfully",
|
||||
Keys: createdKeys,
|
||||
})
|
||||
}
|
||||
|
||||
// list 获取卡密列表
|
||||
func (h *CardKeyHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||
// 获取分页参数
|
||||
pageStr := r.URL.Query().Get("page")
|
||||
pageSizeStr := r.URL.Query().Get("page_size")
|
||||
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
pageSize, _ := strconv.Atoi(pageSizeStr)
|
||||
|
||||
// 默认值
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
total, err := h.repo.Count()
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Failed to count card keys",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
cardKeys, err := h.repo.FindAllPaginated(page, pageSize)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Failed to fetch card keys",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, CardKeyResponse{
|
||||
Success: true,
|
||||
Message: "OK",
|
||||
Keys: cardKeys,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// generateCardKey 生成卡密格式: XXXX-XXXX-XXXX-XXXX
|
||||
func generateCardKey() string {
|
||||
bytes := make([]byte, 8)
|
||||
rand.Read(bytes)
|
||||
hex := strings.ToUpper(hex.EncodeToString(bytes))
|
||||
return hex[0:4] + "-" + hex[4:8] + "-" + hex[8:12] + "-" + hex[12:16]
|
||||
}
|
||||
|
||||
// Delete 删除单个卡密 (DELETE /api/cardkeys/delete?id=xxx)
|
||||
func (h *CardKeyHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Invalid card key ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.Delete(id); err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Failed to delete card key",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, CardKeyResponse{
|
||||
Success: true,
|
||||
Message: "Card key deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// BatchDeleteRequest 批量删除请求
|
||||
type BatchDeleteRequest struct {
|
||||
IDs []int `json:"ids"`
|
||||
}
|
||||
|
||||
// BatchDelete 批量删除卡密 (DELETE /api/cardkeys/batch)
|
||||
func (h *CardKeyHandler) BatchDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req BatchDeleteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.IDs) == 0 {
|
||||
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "No IDs provided",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.BatchDelete(req.IDs); err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Failed to delete card keys",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, CardKeyResponse{
|
||||
Success: true,
|
||||
Message: "Card keys deleted successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// ToggleActiveRequest 切换激活状态请求
|
||||
type ToggleActiveRequest struct {
|
||||
ID int `json:"id"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
// ToggleActive 切换卡密激活状态 (POST /api/cardkeys/toggle)
|
||||
func (h *CardKeyHandler) ToggleActive(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req ToggleActiveRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.ToggleActive(req.ID, req.IsActive); err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, CardKeyResponse{
|
||||
Success: false,
|
||||
Message: "Failed to toggle card key status",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, CardKeyResponse{
|
||||
Success: true,
|
||||
Message: "Card key status updated successfully",
|
||||
})
|
||||
}
|
||||
322
backend/internal/handler/chatgpt_account_handler.go
Normal file
322
backend/internal/handler/chatgpt_account_handler.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gpt-manager-go/internal/models"
|
||||
"gpt-manager-go/internal/repository"
|
||||
"gpt-manager-go/internal/service"
|
||||
)
|
||||
|
||||
// ChatGPTAccountHandler ChatGPT 账号处理器
|
||||
type ChatGPTAccountHandler struct {
|
||||
repo *repository.ChatGPTAccountRepository
|
||||
chatgptService *service.ChatGPTService
|
||||
}
|
||||
|
||||
// NewChatGPTAccountHandler 创建处理器
|
||||
func NewChatGPTAccountHandler(repo *repository.ChatGPTAccountRepository, chatgptService *service.ChatGPTService) *ChatGPTAccountHandler {
|
||||
return &ChatGPTAccountHandler{
|
||||
repo: repo,
|
||||
chatgptService: chatgptService,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAccountRequest 创建账号请求
|
||||
type CreateAccountRequest struct {
|
||||
Name string `json:"name"`
|
||||
TeamAccountID string `json:"team_account_id"`
|
||||
AuthToken string `json:"auth_token"`
|
||||
}
|
||||
|
||||
// AccountResponse 账号响应
|
||||
type AccountResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data *models.ChatGPTAccount `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// ListResponse 列表响应
|
||||
type ListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data []*models.ChatGPTAccount `json:"data,omitempty"`
|
||||
Total int `json:"total,omitempty"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"page_size,omitempty"`
|
||||
}
|
||||
|
||||
// Create 创建账号
|
||||
func (h *ChatGPTAccountHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateAccountRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if req.TeamAccountID == "" || req.AuthToken == "" {
|
||||
respondJSON(w, http.StatusBadRequest, AccountResponse{
|
||||
Success: false,
|
||||
Message: "team_account_id and auth_token are required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
existing, err := h.repo.FindByTeamAccountID(req.TeamAccountID)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Database error: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if existing != nil {
|
||||
respondJSON(w, http.StatusConflict, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Team account already exists",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用 ChatGPT API 获取订阅信息
|
||||
subInfo, err := h.chatgptService.GetSubscription(req.TeamAccountID, req.AuthToken)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Failed to verify team account: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 设置账号名称
|
||||
name := req.Name
|
||||
if name == "" {
|
||||
name = "Team-" + req.TeamAccountID[:8]
|
||||
}
|
||||
|
||||
// 创建账号
|
||||
account := &models.ChatGPTAccount{
|
||||
Name: name,
|
||||
AuthToken: req.AuthToken,
|
||||
TeamAccountID: req.TeamAccountID,
|
||||
SeatsInUse: subInfo.SeatsInUse,
|
||||
SeatsEntitled: subInfo.SeatsEntitled,
|
||||
IsActive: subInfo.IsValid,
|
||||
}
|
||||
|
||||
// 设置时间
|
||||
if subInfo.IsValid {
|
||||
account.ActiveStart = sql.NullTime{Time: subInfo.ActiveStart, Valid: !subInfo.ActiveStart.IsZero()}
|
||||
account.ActiveUntil = sql.NullTime{Time: subInfo.ActiveUntil, Valid: !subInfo.ActiveUntil.IsZero()}
|
||||
account.LastCheck = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
}
|
||||
|
||||
if err := h.repo.Create(account); err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Failed to create account: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 隐藏敏感信息
|
||||
account.AuthToken = ""
|
||||
|
||||
respondJSON(w, http.StatusCreated, AccountResponse{
|
||||
Success: true,
|
||||
Message: "Account created successfully",
|
||||
Data: account,
|
||||
})
|
||||
}
|
||||
|
||||
// List 获取账号列表
|
||||
func (h *ChatGPTAccountHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, ListResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取分页参数
|
||||
pageStr := r.URL.Query().Get("page")
|
||||
pageSizeStr := r.URL.Query().Get("page_size")
|
||||
|
||||
page, _ := strconv.Atoi(pageStr)
|
||||
pageSize, _ := strconv.Atoi(pageSizeStr)
|
||||
|
||||
// 默认值
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
total, err := h.repo.Count()
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, ListResponse{
|
||||
Success: false,
|
||||
Message: "Failed to count accounts",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取分页数据
|
||||
accounts, err := h.repo.FindAllPaginated(page, pageSize)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, ListResponse{
|
||||
Success: false,
|
||||
Message: "Failed to fetch accounts",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 隐藏敏感信息
|
||||
for _, a := range accounts {
|
||||
a.AuthToken = ""
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, ListResponse{
|
||||
Success: true,
|
||||
Message: "OK",
|
||||
Data: accounts,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
}
|
||||
|
||||
// Refresh 刷新账号信息
|
||||
func (h *ChatGPTAccountHandler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取账号 ID
|
||||
idStr := r.URL.Query().Get("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Invalid account ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
account, err := h.repo.FindByID(id)
|
||||
if err != nil || account == nil {
|
||||
respondJSON(w, http.StatusNotFound, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Account not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用 ChatGPT API 获取订阅信息
|
||||
subInfo, err := h.chatgptService.GetSubscription(account.TeamAccountID, account.AuthToken)
|
||||
if err != nil {
|
||||
// API 调用失败,增加失败计数并设为不活跃
|
||||
account.ConsecutiveFailures++
|
||||
account.IsActive = false
|
||||
account.LastCheck = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
h.repo.Update(account)
|
||||
|
||||
respondJSON(w, http.StatusInternalServerError, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Failed to refresh: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新账号信息
|
||||
if subInfo.IsValid {
|
||||
account.SeatsInUse = subInfo.SeatsInUse
|
||||
account.SeatsEntitled = subInfo.SeatsEntitled
|
||||
account.ActiveStart = sql.NullTime{Time: subInfo.ActiveStart, Valid: !subInfo.ActiveStart.IsZero()}
|
||||
account.ActiveUntil = sql.NullTime{Time: subInfo.ActiveUntil, Valid: !subInfo.ActiveUntil.IsZero()}
|
||||
account.IsActive = true
|
||||
account.ConsecutiveFailures = 0
|
||||
} else {
|
||||
account.IsActive = false
|
||||
account.ConsecutiveFailures++
|
||||
}
|
||||
account.LastCheck = sql.NullTime{Time: time.Now(), Valid: true}
|
||||
|
||||
if err := h.repo.Update(account); err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Failed to update account",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 隐藏敏感信息
|
||||
account.AuthToken = ""
|
||||
|
||||
respondJSON(w, http.StatusOK, AccountResponse{
|
||||
Success: true,
|
||||
Message: "Account refreshed successfully",
|
||||
Data: account,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete 删除账号
|
||||
func (h *ChatGPTAccountHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Invalid account ID",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.repo.Delete(id); err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, AccountResponse{
|
||||
Success: false,
|
||||
Message: "Failed to delete account",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, AccountResponse{
|
||||
Success: true,
|
||||
Message: "Account deleted successfully",
|
||||
})
|
||||
}
|
||||
445
backend/internal/handler/invite_handler.go
Normal file
445
backend/internal/handler/invite_handler.go
Normal file
@@ -0,0 +1,445 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"gpt-manager-go/internal/models"
|
||||
"gpt-manager-go/internal/repository"
|
||||
"gpt-manager-go/internal/service"
|
||||
)
|
||||
|
||||
// InviteHandler 邀请处理器
|
||||
type InviteHandler struct {
|
||||
accountRepo *repository.ChatGPTAccountRepository
|
||||
invitationRepo *repository.InvitationRepository
|
||||
cardKeyRepo *repository.CardKeyRepository
|
||||
chatgptService *service.ChatGPTService
|
||||
}
|
||||
|
||||
// NewInviteHandler 创建邀请处理器
|
||||
func NewInviteHandler(
|
||||
accountRepo *repository.ChatGPTAccountRepository,
|
||||
invitationRepo *repository.InvitationRepository,
|
||||
cardKeyRepo *repository.CardKeyRepository,
|
||||
chatgptService *service.ChatGPTService,
|
||||
) *InviteHandler {
|
||||
return &InviteHandler{
|
||||
accountRepo: accountRepo,
|
||||
invitationRepo: invitationRepo,
|
||||
cardKeyRepo: cardKeyRepo,
|
||||
chatgptService: chatgptService,
|
||||
}
|
||||
}
|
||||
|
||||
// AdminInviteRequest 管理员邀请请求
|
||||
type AdminInviteRequest struct {
|
||||
Email string `json:"email"`
|
||||
AccountID int `json:"account_id"` // 可选,不传则自动选择
|
||||
}
|
||||
|
||||
// CardKeyInviteRequest 卡密邀请请求
|
||||
type CardKeyInviteRequest struct {
|
||||
CardKey string `json:"card_key"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// InviteResponse 邀请响应
|
||||
type InviteResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
InvitationID int `json:"invitation_id,omitempty"`
|
||||
AccountName string `json:"account_name,omitempty"`
|
||||
}
|
||||
|
||||
// InviteListResponse 邀请列表响应
|
||||
type InviteListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Data []*models.Invitation `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// ListByAccount 获取账号的邀请列表 (GET /api/invite?account_id=xxx)
|
||||
func (h *InviteHandler) ListByAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, InviteListResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
accountIDStr := r.URL.Query().Get("account_id")
|
||||
if accountIDStr == "" {
|
||||
respondJSON(w, http.StatusBadRequest, InviteListResponse{
|
||||
Success: false,
|
||||
Message: "account_id is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var accountID int
|
||||
if _, err := fmt.Sscanf(accountIDStr, "%d", &accountID); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, InviteListResponse{
|
||||
Success: false,
|
||||
Message: "Invalid account_id",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
invitations, err := h.invitationRepo.FindByAccountID(accountID)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, InviteListResponse{
|
||||
Success: false,
|
||||
Message: "Failed to fetch invitations",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, InviteListResponse{
|
||||
Success: true,
|
||||
Message: "OK",
|
||||
Data: invitations,
|
||||
})
|
||||
}
|
||||
|
||||
// InviteByAdmin 管理员邀请/移除接口 (POST/DELETE /api/invite)
|
||||
func (h *InviteHandler) InviteByAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
h.handleInvite(w, r)
|
||||
case http.MethodDelete:
|
||||
h.handleRemoveMember(w, r)
|
||||
default:
|
||||
respondJSON(w, http.StatusMethodNotAllowed, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleInvite 处理邀请逻辑
|
||||
func (h *InviteHandler) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req AdminInviteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证邮箱
|
||||
if req.Email == "" {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Email is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取账号
|
||||
var account *models.ChatGPTAccount
|
||||
var err error
|
||||
|
||||
if req.AccountID > 0 {
|
||||
// 指定账号
|
||||
account, err = h.accountRepo.FindByID(req.AccountID)
|
||||
if err != nil || account == nil {
|
||||
respondJSON(w, http.StatusNotFound, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Account not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
if !account.IsActive || account.AvailableSeats() <= 0 {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Account is not active or has no available seats",
|
||||
})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 自动选择可用账号
|
||||
account, err = h.findAvailableAccount()
|
||||
if err != nil || account == nil {
|
||||
respondJSON(w, http.StatusServiceUnavailable, InviteResponse{
|
||||
Success: false,
|
||||
Message: "No available team accounts",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 发送邀请
|
||||
inviteResp, err := h.chatgptService.SendInvite(account.TeamAccountID, account.AuthToken, req.Email)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Failed to send invite: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建邀请记录
|
||||
invitation := &models.Invitation{
|
||||
AccountID: account.ID,
|
||||
InvitedEmail: req.Email,
|
||||
Status: models.StatusSent,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if inviteResp.Success {
|
||||
invitation.Status = models.StatusSent
|
||||
} else {
|
||||
invitation.Status = models.StatusFailed
|
||||
invitation.ErrorMessage = sql.NullString{String: inviteResp.Message, Valid: true}
|
||||
}
|
||||
|
||||
if err := h.invitationRepo.Create(invitation); err != nil {
|
||||
// 记录失败不影响主流程
|
||||
}
|
||||
|
||||
if !inviteResp.Success {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: inviteResp.Message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 更新账号使用时间
|
||||
h.accountRepo.UpdateLastUsed(account.ID)
|
||||
|
||||
respondJSON(w, http.StatusOK, InviteResponse{
|
||||
Success: true,
|
||||
Message: "Invitation sent successfully",
|
||||
InvitationID: invitation.ID,
|
||||
AccountName: account.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// InviteByCardKey 卡密邀请 (POST /api/invite/card)
|
||||
func (h *InviteHandler) InviteByCardKey(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
respondJSON(w, http.StatusMethodNotAllowed, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req CardKeyInviteRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if req.CardKey == "" || req.Email == "" {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Card key and email are required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证卡密
|
||||
cardKey, err := h.cardKeyRepo.FindByKey(req.CardKey)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Database error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if cardKey == nil {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Invalid card key",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查卡密是否可用
|
||||
if !cardKey.IsUsable() {
|
||||
if cardKey.IsExpired() {
|
||||
respondJSON(w, http.StatusForbidden, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Card key has expired",
|
||||
})
|
||||
} else if cardKey.UsedCount >= cardKey.MaxUses {
|
||||
respondJSON(w, http.StatusForbidden, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Card key usage limit exceeded",
|
||||
})
|
||||
} else {
|
||||
respondJSON(w, http.StatusForbidden, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Card key is not active",
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 查找可用账号
|
||||
account, err := h.findAvailableAccount()
|
||||
if err != nil || account == nil {
|
||||
respondJSON(w, http.StatusServiceUnavailable, InviteResponse{
|
||||
Success: false,
|
||||
Message: "No available team accounts",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 发送邀请
|
||||
inviteResp, err := h.chatgptService.SendInvite(account.TeamAccountID, account.AuthToken, req.Email)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Failed to send invite: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建邀请记录
|
||||
invitation := &models.Invitation{
|
||||
CardKeyID: sql.NullInt64{Int64: int64(cardKey.ID), Valid: true},
|
||||
AccountID: account.ID,
|
||||
InvitedEmail: req.Email,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if inviteResp.Success {
|
||||
invitation.Status = models.StatusSent
|
||||
} else {
|
||||
invitation.Status = models.StatusFailed
|
||||
invitation.ErrorMessage = sql.NullString{String: inviteResp.Message, Valid: true}
|
||||
}
|
||||
|
||||
if err := h.invitationRepo.Create(invitation); err != nil {
|
||||
// 记录失败不影响主流程
|
||||
}
|
||||
|
||||
if !inviteResp.Success {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: inviteResp.Message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 增加卡密使用次数
|
||||
h.cardKeyRepo.IncrementUsedCount(cardKey.ID)
|
||||
|
||||
// 更新账号使用时间
|
||||
h.accountRepo.UpdateLastUsed(account.ID)
|
||||
|
||||
respondJSON(w, http.StatusOK, InviteResponse{
|
||||
Success: true,
|
||||
Message: "Invitation sent successfully",
|
||||
})
|
||||
}
|
||||
|
||||
// findAvailableAccount 查找可用账号(轮询)
|
||||
func (h *InviteHandler) findAvailableAccount() (*models.ChatGPTAccount, error) {
|
||||
accounts, err := h.accountRepo.FindActiveWithAvailableSeats()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// 返回第一个(已按可用席位数降序、最后使用时间升序排序)
|
||||
return accounts[0], nil
|
||||
}
|
||||
|
||||
// RemoveMemberRequest 移除成员请求
|
||||
type RemoveMemberRequest struct {
|
||||
Email string `json:"email"`
|
||||
AccountID int `json:"account_id"` // 必填,指定从哪个账号移除
|
||||
}
|
||||
|
||||
// handleRemoveMember 处理移除成员逻辑
|
||||
func (h *InviteHandler) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req RemoveMemberRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Invalid request body",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证必填字段
|
||||
if req.Email == "" {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Email is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.AccountID <= 0 {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Account ID is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取账号
|
||||
account, err := h.accountRepo.FindByID(req.AccountID)
|
||||
if err != nil || account == nil {
|
||||
respondJSON(w, http.StatusNotFound, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Account not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !account.IsActive {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Account is not active",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 调用 ChatGPT API 移除成员
|
||||
removeResp, err := h.chatgptService.RemoveMember(account.TeamAccountID, account.AuthToken, req.Email)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusInternalServerError, InviteResponse{
|
||||
Success: false,
|
||||
Message: "Failed to remove member: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !removeResp.Success {
|
||||
respondJSON(w, http.StatusBadRequest, InviteResponse{
|
||||
Success: false,
|
||||
Message: removeResp.Message,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除邀请记录
|
||||
if err := h.invitationRepo.DeleteByEmailAndAccountID(req.Email, req.AccountID); err != nil {
|
||||
// 删除记录失败不影响主流程,只记录日志
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, InviteResponse{
|
||||
Success: true,
|
||||
Message: "Member removed successfully",
|
||||
AccountName: account.Name,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user