Files
MeBox/internal/service/bot_features.go
T
shuk shuk 22b64d3d47 fix(organize): strip release tags/roman numerals/season markers; de-hardcode paths
feat(bot): button menu, capacity/open-reg quota, redemption codes, user mgmt,
account expiry + signin streak, device anti-sharing + inactivity cleanup,
one-click kick, self-service username/password

- Consolidate organize/rename defaults into Tools panel

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-06-07 09:54:14 +08:00

266 lines
9.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"crypto/rand"
"fmt"
"strconv"
"strings"
"time"
"github.com/ShukeBta/MediaStationGo/internal/model"
)
// ── 容量 / 开注名额 ──────────────────────────────────────────────────────────
// capacityInfo 描述当前用户容量(随凭证授权实时变化)与开注名额状态。
type capacityInfo struct {
UsedUsers int64
MaxUsers int64 // 来自 LicensedMaxUsers,随授权实时变化
OpenRegOn bool
OpenRegLimit int // 0 = 不限(仅受 MaxUsers 约束)
OpenRegUsed int
}
// Remaining 返回还能注册多少个账号(同时受授权上限与开注名额约束)。
func (c capacityInfo) Remaining() int64 {
byLicense := c.MaxUsers - c.UsedUsers
if byLicense < 0 {
byLicense = 0
}
if c.OpenRegLimit > 0 {
byQuota := int64(c.OpenRegLimit - c.OpenRegUsed)
if byQuota < 0 {
byQuota = 0
}
if byQuota < byLicense {
return byQuota
}
}
return byLicense
}
// loadCapacity reads live capacity + open-reg quota state.
func (s *TelegramBotService) loadCapacity(ctx context.Context) capacityInfo {
used, _ := s.repo.User.Count(ctx)
info := capacityInfo{
UsedUsers: used,
MaxUsers: LicensedMaxUsers(ctx, s.repo),
OpenRegOn: s.openRegEnabled(ctx),
OpenRegLimit: s.intSetting(ctx, SettingOpenRegLimit, 0),
OpenRegUsed: s.intSetting(ctx, SettingOpenRegUsed, 0),
}
return info
}
func (s *TelegramBotService) intSetting(ctx context.Context, key string, fallback int) int {
v, err := s.repo.Setting.Get(ctx, key)
if err != nil {
return fallback
}
return parseIntSettingDefault(v, fallback)
}
// openRegEnabled reports whether bot registration is currently open. It honours
// both the new open-reg switch and the legacy registration switch.
func (s *TelegramBotService) openRegEnabled(ctx context.Context) bool {
if v, _ := s.repo.Setting.Get(ctx, SettingOpenRegEnabled); v != "" {
return parseBoolSetting(v, false)
}
return s.registrationEnabled(ctx)
}
// openRegistration opens registration for `limit` new accounts (0 = unlimited,
// bounded only by the license). Resets the used counter.
func (s *TelegramBotService) openRegistration(ctx context.Context, limit int) error {
if limit < 0 {
limit = 0
}
if err := s.repo.Setting.Set(ctx, SettingOpenRegEnabled, "true"); err != nil {
return err
}
if err := s.repo.Setting.Set(ctx, SettingOpenRegLimit, strconv.Itoa(limit)); err != nil {
return err
}
if err := s.repo.Setting.Set(ctx, SettingOpenRegUsed, "0"); err != nil {
return err
}
// 与旧开关同步,兼容系统设置页。
return s.setRegistrationEnabled(ctx, true)
}
// closeRegistration disables bot registration.
func (s *TelegramBotService) closeRegistration(ctx context.Context) error {
if err := s.repo.Setting.Set(ctx, SettingOpenRegEnabled, "false"); err != nil {
return err
}
return s.setRegistrationEnabled(ctx, false)
}
// consumeOpenRegSlot increments the used counter and auto-closes registration
// once the quota is exhausted. Call after a successful bot registration.
func (s *TelegramBotService) consumeOpenRegSlot(ctx context.Context) {
limit := s.intSetting(ctx, SettingOpenRegLimit, 0)
used := s.intSetting(ctx, SettingOpenRegUsed, 0) + 1
_ = s.repo.Setting.Set(ctx, SettingOpenRegUsed, strconv.Itoa(used))
if limit > 0 && used >= limit {
_ = s.closeRegistration(ctx)
}
}
// ── 兑换码 ──────────────────────────────────────────────────────────────────
// generateCode creates a random redemption code of the given kind. durationDays
// sets the account validity granted on redeem (0 = permanent). validDays sets
// how long the code itself stays redeemable (0 = never expires).
func (s *TelegramBotService) generateCode(ctx context.Context, kind string, durationDays, validDays int, createdBy string) (*model.RegistrationCode, error) {
if kind != model.RegistrationCodeRegister && kind != model.RegistrationCodeRenew {
kind = model.RegistrationCodeRegister
}
code := &model.RegistrationCode{
Code: randomCode(12),
Kind: kind,
DurationDays: durationDays,
CreatedByID: createdBy,
}
if validDays > 0 {
exp := time.Now().Add(time.Duration(validDays) * 24 * time.Hour)
code.ExpiresAt = &exp
}
if err := s.repo.RegCode.Create(ctx, code); err != nil {
return nil, err
}
return code, nil
}
// lookupRedeemableCode validates a code without consuming it. Callers mark it
// used only after the dependent action (account create / renew) succeeds, so a
// failed action never burns a code.
func (s *TelegramBotService) lookupRedeemableCode(ctx context.Context, raw, wantKind string) (*model.RegistrationCode, string) {
code := strings.TrimSpace(raw)
if code == "" {
return nil, "请提供兑换码。"
}
rc, err := s.repo.RegCode.FindByCode(ctx, code)
if err != nil || rc == nil {
return nil, "兑换码无效。"
}
if rc.IsUsed() {
return nil, "兑换码已被使用。"
}
if rc.IsExpired() {
return nil, "兑换码已过期。"
}
if wantKind != "" && rc.Kind != wantKind {
switch rc.Kind {
case model.RegistrationCodeRenew:
return nil, "这是续期兑换码,请在「我的账号」里使用它续期。"
default:
return nil, "这是注册兑换码,请用于注册新账号。"
}
}
return rc, ""
}
// ── 续期 ────────────────────────────────────────────────────────────────────
// renewUser extends a user's expiry by durationDays. A nil/zero current expiry
// starts from now; a future expiry is extended from that point. durationDays<=0
// sets the account to never expire (permanent).
func renewExpiry(current *time.Time, durationDays int) *time.Time {
if durationDays <= 0 {
return nil // permanent
}
base := time.Now()
if current != nil && current.After(base) {
base = *current
}
exp := base.Add(time.Duration(durationDays) * 24 * time.Hour)
return &exp
}
// applyRenewal renews a user account and clears any expiry-related suspension.
func (s *TelegramBotService) applyRenewal(ctx context.Context, userID string, durationDays int) error {
u, err := s.repo.User.FindByID(ctx, userID)
if err != nil || u == nil {
return fmt.Errorf("user not found")
}
exp := renewExpiry(u.ExpiredAt, durationDays)
updates := map[string]any{"expired_at": exp, "is_active": true}
return s.repo.User.UpdateFields(ctx, userID, updates)
}
// ── 签到 ────────────────────────────────────────────────────────────────────
// signInResult 描述一次签到的结果。
type signInResult struct {
AlreadySigned bool
Streak int
Total int
}
// signIn records a daily sign-in for the user, tracking consecutive-day streaks
// only (no points). A second sign-in on the same calendar day is a no-op.
func (s *TelegramBotService) signIn(ctx context.Context, userID string) (signInResult, error) {
now := time.Now()
today := now.Truncate(24 * time.Hour)
rec, err := s.repo.SignIn.Get(ctx, userID)
if err != nil {
return signInResult{}, err
}
if rec == nil {
rec = &model.SignIn{UserID: userID, LastSignIn: now, StreakDays: 1, TotalDays: 1}
if err := s.repo.SignIn.Save(ctx, rec); err != nil {
return signInResult{}, err
}
return signInResult{Streak: 1, Total: 1}, nil
}
last := rec.LastSignIn.Truncate(24 * time.Hour)
switch {
case last.Equal(today):
return signInResult{AlreadySigned: true, Streak: rec.StreakDays, Total: rec.TotalDays}, nil
case last.Equal(today.Add(-24 * time.Hour)):
rec.StreakDays++
default:
rec.StreakDays = 1 // streak broken
}
rec.TotalDays++
rec.LastSignIn = now
if err := s.repo.SignIn.Save(ctx, rec); err != nil {
return signInResult{}, err
}
return signInResult{Streak: rec.StreakDays, Total: rec.TotalDays}, nil
}
// ── helpers ─────────────────────────────────────────────────────────────────
const codeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // no ambiguous 0/O/1/I
func randomCode(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
// crypto/rand failure is extremely unlikely; fall back to time noise.
seed := time.Now().UnixNano()
for i := range b {
b[i] = byte(seed >> (uint(i%8) * 8))
}
}
out := make([]byte, n)
for i := range b {
out[i] = codeAlphabet[int(b[i])%len(codeAlphabet)]
}
return string(out)
}
// formatExpiry renders a user's expiry status for display.
func formatExpiry(t *time.Time) string {
if t == nil {
return "永久有效"
}
if time.Now().After(*t) {
return "已过期(" + t.Format("2006-01-02") + ")"
}
days := int(time.Until(*t).Hours() / 24)
return fmt.Sprintf("%s(剩 %d 天)", t.Format("2006-01-02"), days)
}