✨ 新增几大中心功能
This commit is contained in:
30
server/service/system/app_online_config.go
Normal file
30
server/service/system/app_online_config.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.echol.cn/loser/Go-Web-Template/server/global"
|
||||
"git.echol.cn/loser/Go-Web-Template/server/model/system"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const paramAppOnlineWindowMinutes = "app.online_active_window_minutes"
|
||||
|
||||
// GetAppOnlineWindowMinutes 在线判定窗口(分钟),默认 5
|
||||
func GetAppOnlineWindowMinutes() int {
|
||||
var rec system.SysParams
|
||||
err := global.GVA_DB.Where("key = ?", paramAppOnlineWindowMinutes).First(&rec).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return 5
|
||||
}
|
||||
return 5
|
||||
}
|
||||
v := strings.TrimSpace(rec.Value)
|
||||
n, e := strconv.Atoi(v)
|
||||
if e != nil || n <= 0 || n > 1440 {
|
||||
return 5
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -5,6 +5,8 @@ type ServiceGroup struct {
|
||||
ApiService
|
||||
MenuService
|
||||
UserService
|
||||
InviteCodeService
|
||||
PublicRegisterService
|
||||
CasbinService
|
||||
InitDBService
|
||||
BaseMenuService
|
||||
|
||||
144
server/service/system/invite_code.go
Normal file
144
server/service/system/invite_code.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.echol.cn/loser/Go-Web-Template/server/global"
|
||||
sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type InviteCodeService struct{}
|
||||
|
||||
var InviteCodeServiceApp = new(InviteCodeService)
|
||||
|
||||
type InviteCodeCurrent struct {
|
||||
HasUnused bool `json:"hasUnused"`
|
||||
Record *sysModel.InviteCode `json:"record,omitempty"`
|
||||
}
|
||||
|
||||
type InviteCodeGenerated struct {
|
||||
Code string `json:"code"`
|
||||
CodeLast4 string `json:"codeLast4"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
func sha256Hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func generateCode() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
enc := base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
return strings.ToLower(enc.EncodeToString(buf)), nil
|
||||
}
|
||||
|
||||
func (s *InviteCodeService) GetCurrentUnused(userID uint) (*sysModel.InviteCode, error) {
|
||||
var record sysModel.InviteCode
|
||||
now := time.Now()
|
||||
err := global.GVA_DB.
|
||||
Where("created_by_user_id = ? AND status = ?", userID, sysModel.InviteCodeStatusUnused).
|
||||
Where(global.GVA_DB.Where("expires_at IS NULL").Or("expires_at > ?", now)).
|
||||
Order("id desc").
|
||||
First(&record).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &record, err
|
||||
}
|
||||
|
||||
func (s *InviteCodeService) Generate(userID uint, expireHours int) (*InviteCodeGenerated, error) {
|
||||
if expireHours <= 0 {
|
||||
expireHours = 24
|
||||
}
|
||||
|
||||
// 单活跃未使用码:存在则拒绝生成(用户已选择该策略)
|
||||
existing, err := s.GetCurrentUnused(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, errors.New("已有未使用邀请码")
|
||||
}
|
||||
|
||||
code, err := generateCode()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
last4 := ""
|
||||
if len(code) >= 4 {
|
||||
last4 = code[len(code)-4:]
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if expireHours > 0 {
|
||||
t := time.Now().Add(time.Duration(expireHours) * time.Hour)
|
||||
expiresAt = &t
|
||||
}
|
||||
|
||||
record := sysModel.InviteCode{
|
||||
CreatedByUserID: userID,
|
||||
CodeHash: sha256Hex(code),
|
||||
CodeLast4: last4,
|
||||
Status: sysModel.InviteCodeStatusUnused,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
if err := global.GVA_DB.Create(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &InviteCodeGenerated{Code: code, CodeLast4: last4, ExpiresAt: expiresAt}, nil
|
||||
}
|
||||
|
||||
// ConsumeByPlainCode 校验并消耗邀请码(用于注册邀请码模式,需事务调用)
|
||||
func (s *InviteCodeService) ConsumeByPlainCode(tx *gorm.DB, code string, inviteeUserID uint) (*sysModel.InviteCode, error) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return nil, errors.New("邀请码不能为空")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var record sysModel.InviteCode
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("code_hash = ? AND status = ?", sha256Hex(code), sysModel.InviteCodeStatusUnused).
|
||||
Where(tx.Where("expires_at IS NULL").Or("expires_at > ?", now)).
|
||||
First(&record).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("邀请码无效")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
usedAt := time.Now()
|
||||
record.Status = sysModel.InviteCodeStatusUsed
|
||||
record.UsedByUserID = &inviteeUserID
|
||||
record.UsedAt = &usedAt
|
||||
if err := tx.Model(&record).Select("status", "used_by_user_id", "used_at").Updates(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 写邀请关系(invitee 唯一)
|
||||
rel := sysModel.UserInviteRelation{
|
||||
InviterUserID: record.CreatedByUserID,
|
||||
InviteeUserID: inviteeUserID,
|
||||
InviteCodeID: record.ID,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if err := tx.Create(&rel).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
70
server/service/system/public_register.go
Normal file
70
server/service/system/public_register.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.echol.cn/loser/Go-Web-Template/server/global"
|
||||
sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system"
|
||||
systemReq "git.echol.cn/loser/Go-Web-Template/server/model/system/request"
|
||||
"git.echol.cn/loser/Go-Web-Template/server/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PublicRegisterService struct{}
|
||||
|
||||
var PublicRegisterServiceApp = new(PublicRegisterService)
|
||||
|
||||
func (s *PublicRegisterService) Register(_ *gin.Context, req systemReq.PublicRegister, mode RegisterMode) error {
|
||||
username := strings.TrimSpace(req.Username)
|
||||
password := req.Password
|
||||
welcome := strings.TrimSpace(req.WelcomePhrase)
|
||||
|
||||
if username == "" || password == "" || welcome == "" {
|
||||
return errors.New("参数不完整")
|
||||
}
|
||||
if len(welcome) > 255 {
|
||||
return errors.New("欢迎词过长")
|
||||
}
|
||||
|
||||
if mode == RegisterModeInvite && strings.TrimSpace(req.InviteCode) == "" {
|
||||
return errors.New("邀请码不能为空")
|
||||
}
|
||||
|
||||
return global.GVA_DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 用户名唯一校验
|
||||
var existed sysModel.SysUser
|
||||
if err := tx.Where("username = ?", username).First(&existed).Error; err == nil {
|
||||
return errors.New("用户名已注册")
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
user := sysModel.SysUser{
|
||||
UUID: uuid.New(),
|
||||
Username: username,
|
||||
Password: utils.BcryptHash(password),
|
||||
NickName: username,
|
||||
WelcomePhrase: welcome,
|
||||
AuthorityId: 9528, // 用户已选择:注册用户默认绑定现有 9528 角色
|
||||
Enable: 1,
|
||||
}
|
||||
if err := tx.Create(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if mode == RegisterModeInvite {
|
||||
if _, err := InviteCodeServiceApp.ConsumeByPlainCode(tx, req.InviteCode, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Authorities 关联:保持与原系统一致,给用户绑定其 AuthorityId 对应角色
|
||||
if err := tx.Model(&user).Association("Authorities").Replace([]sysModel.SysAuthority{{AuthorityId: user.AuthorityId}}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
84
server/service/system/register_config.go
Normal file
84
server/service/system/register_config.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"git.echol.cn/loser/Go-Web-Template/server/global"
|
||||
sysModel "git.echol.cn/loser/Go-Web-Template/server/model/system"
|
||||
)
|
||||
|
||||
const (
|
||||
paramRegisterMode = "auth.register_mode"
|
||||
paramRegisterRequireCaptcha = "auth.register_require_captcha"
|
||||
paramInviteExpireHours = "auth.invite_code_expire_hours"
|
||||
)
|
||||
|
||||
type RegisterMode string
|
||||
|
||||
const (
|
||||
RegisterModeClosed RegisterMode = "closed"
|
||||
RegisterModeOpen RegisterMode = "open"
|
||||
RegisterModeInvite RegisterMode = "invite"
|
||||
)
|
||||
|
||||
var allowedInviteExpireHours = map[int]bool{
|
||||
12: true,
|
||||
24: true,
|
||||
72: true,
|
||||
168: true,
|
||||
720: true,
|
||||
}
|
||||
|
||||
func getSysParamValue(key string) (string, bool) {
|
||||
var param sysModel.SysParams
|
||||
if err := global.GVA_DB.Where(sysModel.SysParams{Key: key}).First(¶m).Error; err != nil {
|
||||
return "", false
|
||||
}
|
||||
return param.Value, true
|
||||
}
|
||||
|
||||
func GetRegisterMode() RegisterMode {
|
||||
if v, ok := getSysParamValue(paramRegisterMode); ok {
|
||||
switch RegisterMode(v) {
|
||||
case RegisterModeClosed, RegisterModeOpen, RegisterModeInvite:
|
||||
return RegisterMode(v)
|
||||
}
|
||||
}
|
||||
return RegisterModeClosed
|
||||
}
|
||||
|
||||
func GetRegisterRequireCaptcha() bool {
|
||||
if v, ok := getSysParamValue(paramRegisterRequireCaptcha); ok {
|
||||
return v == "1" || v == "true" || v == "on"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func GetInviteExpireHours() int {
|
||||
if v, ok := getSysParamValue(paramInviteExpireHours); ok {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
if allowedInviteExpireHours[n] {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 24
|
||||
}
|
||||
|
||||
// ---- 供 API 层调用的实例方法(避免跨包直接引用自由函数) ----
|
||||
|
||||
func (s *PublicRegisterService) RegisterMode() RegisterMode {
|
||||
return GetRegisterMode()
|
||||
}
|
||||
|
||||
func (s *PublicRegisterService) RequireCaptcha() bool {
|
||||
return GetRegisterRequireCaptcha()
|
||||
}
|
||||
|
||||
func (s *PublicRegisterService) InviteExpireHours() int {
|
||||
return GetInviteExpireHours()
|
||||
}
|
||||
|
||||
func (s *InviteCodeService) InviteExpireHours() int {
|
||||
return GetInviteExpireHours()
|
||||
}
|
||||
@@ -102,10 +102,20 @@ func (apiService *ApiService) SyncApi() (newApis, deleteApis, ignoreApis []syste
|
||||
}
|
||||
}
|
||||
if !flag {
|
||||
// prefill ApiGroup/Description from default meta map if available
|
||||
apiGroup := ""
|
||||
desc := ""
|
||||
if global.GVA_API_DEFAULT_META != nil {
|
||||
k := strings.ToUpper(strings.TrimSpace(cacheApis[i].Method)) + " " + strings.TrimSpace(cacheApis[i].Path)
|
||||
if m, ok := global.GVA_API_DEFAULT_META[k]; ok {
|
||||
apiGroup = m.ApiGroup
|
||||
desc = m.Description
|
||||
}
|
||||
}
|
||||
newApis = append(newApis, system.SysApi{
|
||||
Path: cacheApis[i].Path,
|
||||
Description: "",
|
||||
ApiGroup: "",
|
||||
Description: desc,
|
||||
ApiGroup: apiGroup,
|
||||
Method: cacheApis[i].Method,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user