/start %s 密码 重新绑定。", user.Username, err.Error(), user.Username)}
}
@@ -836,12 +904,18 @@ func (s *TelegramBotService) handleCallback(ctx context.Context, cb *TelegramCal
}
// 立即应答回调,关闭按钮上的加载状态,避免客户端长时间转圈。
s.answerCallback(ctx, channel, cb.ID)
- switch strings.TrimSpace(cb.Data) {
- case "adult_toggle":
+ data := strings.TrimSpace(cb.Data)
+ if data == "adult_toggle" {
reply := s.cmdHideAdult(ctx, &msg, nil)
if reply.Text != "" {
return s.reply(ctx, channel, cb.Message.Chat.ID, reply)
}
+ return nil
+ }
+ if reply, handled := s.handleMenuCallback(ctx, channel, &msg, data); handled {
+ if reply.Text != "" {
+ return s.reply(ctx, channel, cb.Message.Chat.ID, reply)
+ }
}
return nil
}
diff --git a/internal/service/telegram_menu.go b/internal/service/telegram_menu.go
new file mode 100644
index 0000000..0847f0c
--- /dev/null
+++ b/internal/service/telegram_menu.go
@@ -0,0 +1,608 @@
+package service
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+)
+
+// pendingTTL bounds how long a button-initiated text prompt stays valid.
+const pendingTTL = 5 * time.Minute
+
+func (s *TelegramBotService) setPending(userID int64, kind string) {
+ s.pendingMu.Lock()
+ s.pending[userID] = pendingInput{Kind: kind, CreatedAt: time.Now()}
+ s.pendingMu.Unlock()
+}
+
+func (s *TelegramBotService) takePending(userID int64) (pendingInput, bool) {
+ s.pendingMu.Lock()
+ defer s.pendingMu.Unlock()
+ p, ok := s.pending[userID]
+ if ok {
+ delete(s.pending, userID)
+ }
+ if ok && time.Since(p.CreatedAt) > pendingTTL {
+ return pendingInput{}, false
+ }
+ return p, ok
+}
+
+// boundUser resolves the local user bound to a Telegram account, or nil.
+func (s *TelegramBotService) boundUser(ctx context.Context, telegramUserID int) *model.User {
+ binding := s.telegramBinding(ctx, telegramUserID)
+ if binding == nil {
+ return nil
+ }
+ u, _ := s.repo.User.FindByID(ctx, binding.UserID)
+ return u
+}
+
+// mainMenu builds the button-based menu, tailored to the user's binding and
+// admin status. Ordinary users only see self-service actions; admins get an
+// extra management section.
+func (s *TelegramBotService) mainMenu(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage) telegramCommandReply {
+ isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID)
+ user := s.boundUser(ctx, msg.From.ID)
+
+ var rows [][]telegramInlineButton
+ var header string
+
+ if user == nil {
+ header = "MediaStationGo\n\n你还没有绑定媒体中心账号。"
+ rows = append(rows, []telegramInlineButton{{Text: "🔗 绑定账号", Data: "act_bind"}})
+ if s.openRegEnabled(ctx) {
+ rows = append(rows, []telegramInlineButton{{Text: "📝 注册新账号", Data: "act_register"}})
+ }
+ rows = append(rows, []telegramInlineButton{{Text: "🎟 兑换码注册", Data: "act_redeem_register"}})
+ } else {
+ adult := map[bool]string{true: "已隐藏", false: "已显示"}[user.HideAdult]
+ header = fmt.Sprintf("MediaStationGo\n\n账号:%s\n到期:%s\n成人目录:%s",
+ user.Username, formatExpiry(user.ExpiredAt), adult)
+ rows = append(rows,
+ []telegramInlineButton{
+ {Text: "👤 我的账号", Data: "act_account"},
+ {Text: "📅 签到", Data: "act_signin"},
+ },
+ []telegramInlineButton{
+ {Text: "📱 我的设备", Data: "act_devices"},
+ {Text: map[bool]string{true: "🔞 显示成人目录", false: "🔞 隐藏成人目录"}[user.HideAdult], Data: "adult_toggle"},
+ },
+ []telegramInlineButton{
+ {Text: "✏️ 改用户名", Data: "act_setname"},
+ {Text: "🔑 改密码", Data: "act_setpass"},
+ },
+ []telegramInlineButton{{Text: "🎟 兑换码续期", Data: "act_redeem_renew"}},
+ )
+ }
+
+ if isAdmin {
+ rows = append(rows,
+ []telegramInlineButton{{Text: "—— 管理员 ——", Data: "noop"}},
+ []telegramInlineButton{
+ {Text: "📊 容量/状态", Data: "adm_capacity"},
+ {Text: "👥 用户管理", Data: "adm_users"},
+ },
+ []telegramInlineButton{
+ {Text: "🔓 开注设置", Data: "adm_openreg"},
+ {Text: "🎟 生成兑换码", Data: "adm_gencode"},
+ },
+ []telegramInlineButton{{Text: "⚙️ 设备策略", Data: "adm_devicepolicy"}},
+ )
+ }
+
+ return telegramCommandReply{Text: header, Buttons: rows}
+}
+
+// handleMenuCallback routes inline-button taps. Returns (reply, handled).
+func (s *TelegramBotService) handleMenuCallback(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, data string) (telegramCommandReply, bool) {
+ isAdmin := s.telegramUserIsAdmin(ctx, channel, msg.From.ID)
+
+ switch {
+ case data == "noop":
+ return telegramCommandReply{}, true
+ case data == "menu_main":
+ return s.mainMenu(ctx, channel, msg), true
+ case data == "act_bind":
+ return telegramCommandReply{Text: "请发送:/start 用户名 密码 绑定已有账号。"}, true
+ case data == "act_register":
+ if !s.openRegEnabled(ctx) {
+ return telegramCommandReply{Text: "注册功能未开放,请联系管理员。"}, true
+ }
+ s.setPending(int64(msg.From.ID), "register")
+ return telegramCommandReply{Text: "请发送新账号的 用户名 密码(空格分隔),例如:alice mypass123"}, true
+ case data == "act_redeem_register":
+ s.setPending(int64(msg.From.ID), "redeem_register")
+ return telegramCommandReply{Text: "请发送你的注册兑换码,例如:ABCD2345EFGH\n(兑换后会要求设置用户名密码)"}, true
+ case data == "act_redeem_renew":
+ s.setPending(int64(msg.From.ID), "redeem_renew")
+ return telegramCommandReply{Text: "请发送你的续期兑换码,将为当前绑定账号续期。"}, true
+ case data == "act_account":
+ return s.replyAccount(ctx, msg), true
+ case data == "act_signin":
+ return s.replySignIn(ctx, msg), true
+ case data == "act_devices":
+ return s.replyDevices(ctx, msg), true
+ case data == "act_setname":
+ s.setPending(int64(msg.From.ID), "setname")
+ return telegramCommandReply{Text: "请发送新的用户名。"}, true
+ case data == "act_setpass":
+ s.setPending(int64(msg.From.ID), "setpass")
+ return telegramCommandReply{Text: "请发送新的密码(至少 6 位)。"}, true
+ case strings.HasPrefix(data, "kick:"):
+ return s.replyKick(ctx, msg, strings.TrimPrefix(data, "kick:")), true
+ }
+
+ // ── 管理员专属 ──
+ if !isAdmin {
+ return telegramCommandReply{Text: "此功能仅管理员可用。"}, true
+ }
+ switch {
+ case data == "adm_capacity":
+ return s.replyCapacity(ctx), true
+ case data == "adm_openreg":
+ return s.replyOpenRegMenu(ctx), true
+ case data == "adm_openreg_close":
+ _ = s.closeRegistration(ctx)
+ return telegramCommandReply{Text: "已关闭注册。"}, true
+ case strings.HasPrefix(data, "adm_openreg_set:"):
+ n, _ := strconv.Atoi(strings.TrimPrefix(data, "adm_openreg_set:"))
+ if err := s.openRegistration(ctx, n); err != nil {
+ return telegramCommandReply{Text: "开注失败:" + err.Error()}, true
+ }
+ label := "不限"
+ if n > 0 {
+ label = fmt.Sprintf("%d 个名额", n)
+ }
+ return telegramCommandReply{Text: "已开放注册:" + label + "。"}, true
+ case data == "adm_gencode":
+ return s.replyGenCodeMenu(), true
+ case strings.HasPrefix(data, "gc:"):
+ return s.replyGenCode(ctx, msg, data), true
+ case data == "adm_users":
+ return s.replyUserList(ctx), true
+ case strings.HasPrefix(data, "usr:"):
+ return s.replyUserActions(ctx, strings.TrimPrefix(data, "usr:")), true
+ case strings.HasPrefix(data, "uban:"):
+ return s.replyUserBan(ctx, strings.TrimPrefix(data, "uban:"), false), true
+ case strings.HasPrefix(data, "uunban:"):
+ return s.replyUserBan(ctx, strings.TrimPrefix(data, "uunban:"), true), true
+ case strings.HasPrefix(data, "udel:"):
+ return s.replyUserDelete(ctx, strings.TrimPrefix(data, "udel:")), true
+ case strings.HasPrefix(data, "urenew:"):
+ return s.replyUserRenew(ctx, strings.TrimPrefix(data, "urenew:")), true
+ case data == "adm_devicepolicy":
+ return s.replyDevicePolicy(ctx), true
+ case strings.HasPrefix(data, "dp_toggle:"):
+ return s.replyDevicePolicyToggle(ctx, strings.TrimPrefix(data, "dp_toggle:")), true
+ }
+ return telegramCommandReply{}, false
+}
+
+// handlePendingText consumes a button-initiated text prompt. Returns (reply,
+// handled). handled=false means there was no pending prompt for this user.
+func (s *TelegramBotService) handlePendingText(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, text string) (telegramCommandReply, bool) {
+ p, ok := s.takePending(int64(msg.From.ID))
+ if !ok {
+ return telegramCommandReply{}, false
+ }
+ switch p.Kind {
+ case "register":
+ return s.cmdRegister(ctx, channel, msg, strings.Fields(text)), true
+ case "redeem_register":
+ return s.redeemRegisterFlow(ctx, channel, msg, text), true
+ case "redeem_renew":
+ return s.redeemRenewFlow(ctx, msg, text), true
+ case "setname":
+ return s.selfSetName(ctx, msg, text), true
+ case "setpass":
+ return s.selfSetPass(ctx, msg, text), true
+ case "openreg_limit":
+ n, err := strconv.Atoi(strings.TrimSpace(text))
+ if err != nil || n < 0 {
+ return telegramCommandReply{Text: "请输入有效的非负整数。"}, true
+ }
+ if err := s.openRegistration(ctx, n); err != nil {
+ return telegramCommandReply{Text: "开注失败:" + err.Error()}, true
+ }
+ return telegramCommandReply{Text: fmt.Sprintf("已开放注册:%d 个名额。", n)}, true
+ }
+ return telegramCommandReply{}, false
+}
+
+// ── 用户自助 ──────────────────────────────────────────────────────────────
+
+func (s *TelegramBotService) replyAccount(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
+ user := s.boundUser(ctx, msg.From.ID)
+ if user == nil {
+ return telegramCommandReply{Text: "请先绑定账号:/start 用户名 密码"}
+ }
+ streak := 0
+ if rec, _ := s.repo.SignIn.Get(ctx, user.ID); rec != nil {
+ streak = rec.StreakDays
+ }
+ devices, _ := s.repo.UserDevice.ListByUser(ctx, user.ID)
+ text := fmt.Sprintf("我的账号\n\n用户名:%s\n状态:%s\n到期:%s\n连续签到:%d 天\n登录设备:%d 台",
+ user.Username,
+ map[bool]string{true: "正常", false: "已禁用"}[user.IsActive],
+ formatExpiry(user.ExpiredAt), streak, len(devices))
+ return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}}
+}
+
+func (s *TelegramBotService) replySignIn(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
+ user := s.boundUser(ctx, msg.From.ID)
+ if user == nil {
+ return telegramCommandReply{Text: "请先绑定账号后再签到。"}
+ }
+ res, err := s.signIn(ctx, user.ID)
+ if err != nil {
+ return telegramCommandReply{Text: "签到失败:" + err.Error()}
+ }
+ if res.AlreadySigned {
+ return telegramCommandReply{Text: fmt.Sprintf("今天已经签到过啦~\n连续签到 %d 天,累计 %d 天。", res.Streak, res.Total)}
+ }
+ return telegramCommandReply{Text: fmt.Sprintf("签到成功 ✅\n连续签到 %d 天,累计 %d 天。", res.Streak, res.Total)}
+}
+
+func (s *TelegramBotService) replyDevices(ctx context.Context, msg *TelegramMessage) telegramCommandReply {
+ user := s.boundUser(ctx, msg.From.ID)
+ if user == nil {
+ return telegramCommandReply{Text: "请先绑定账号。"}
+ }
+ devices, _ := s.repo.UserDevice.ListByUser(ctx, user.ID)
+ if len(devices) == 0 {
+ return telegramCommandReply{Text: "当前没有记录到登录设备。"}
+ }
+ var sb strings.Builder
+ sb.WriteString("我的登录设备\n点击下方按钮可一键踢下线:\n")
+ var rows [][]telegramInlineButton
+ for i, d := range devices {
+ status := ""
+ if d.Kicked {
+ status = "(已踢下线)"
+ }
+ sb.WriteString(fmt.Sprintf("\n%d. %s%s\n 最近活跃:%s", i+1, deviceLabel(d.DeviceName, d.Client), status, d.LastSeenAt.Format("01-02 15:04")))
+ if !d.Kicked {
+ rows = append(rows, []telegramInlineButton{{Text: "🚫 踢下线:" + deviceLabel(d.DeviceName, d.Client), Data: "kick:" + d.ID}})
+ }
+ }
+ rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}})
+ return telegramCommandReply{Text: sb.String(), Buttons: rows}
+}
+
+func (s *TelegramBotService) replyKick(ctx context.Context, msg *TelegramMessage, deviceRowID string) telegramCommandReply {
+ user := s.boundUser(ctx, msg.From.ID)
+ if user == nil {
+ return telegramCommandReply{Text: "请先绑定账号。"}
+ }
+ // Verify the device belongs to this user before kicking.
+ var d model.UserDevice
+ if err := s.repo.DB.WithContext(ctx).Where("id = ? AND user_id = ?", deviceRowID, user.ID).First(&d).Error; err != nil {
+ return telegramCommandReply{Text: "未找到该设备。"}
+ }
+ if err := s.repo.UserDevice.SetKicked(ctx, d.ID, true); err != nil {
+ return telegramCommandReply{Text: "操作失败:" + err.Error()}
+ }
+ return s.replyDevices(ctx, msg)
+}
+
+func (s *TelegramBotService) selfSetName(ctx context.Context, msg *TelegramMessage, newName string) telegramCommandReply {
+ user := s.boundUser(ctx, msg.From.ID)
+ if user == nil {
+ return telegramCommandReply{Text: "请先绑定账号。"}
+ }
+ newName = strings.TrimSpace(newName)
+ if len(newName) < 2 || strings.ContainsAny(newName, " \t\n") {
+ return telegramCommandReply{Text: "用户名至少 2 位且不能含空格,请重试。"}
+ }
+ if existing, _ := s.repo.User.FindByUsername(ctx, newName); existing != nil && existing.ID != user.ID {
+ return telegramCommandReply{Text: "该用户名已被占用,请换一个。"}
+ }
+ if err := s.repo.User.UpdateFields(ctx, user.ID, map[string]any{"username": newName}); err != nil {
+ return telegramCommandReply{Text: "修改失败:" + err.Error()}
+ }
+ return telegramCommandReply{Text: fmt.Sprintf("用户名已修改为 %s。请用新用户名登录。", newName)}
+}
+
+func (s *TelegramBotService) selfSetPass(ctx context.Context, msg *TelegramMessage, newPass string) telegramCommandReply {
+ user := s.boundUser(ctx, msg.From.ID)
+ if user == nil {
+ return telegramCommandReply{Text: "请先绑定账号。"}
+ }
+ newPass = strings.TrimSpace(newPass)
+ if s.auth == nil {
+ return telegramCommandReply{Text: "服务暂不可用。"}
+ }
+ if err := s.auth.ResetPassword(ctx, user.ID, newPass); err != nil {
+ return telegramCommandReply{Text: "修改失败:" + err.Error()}
+ }
+ return telegramCommandReply{Text: "密码已修改,请用新密码重新登录第三方客户端。"}
+}
+
+// ── 兑换码流程 ───────────────────────────────────────────────────────────────
+
+func (s *TelegramBotService) redeemRegisterFlow(ctx context.Context, channel *model.NotifyChannel, msg *TelegramMessage, raw string) telegramCommandReply {
+ rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRegister)
+ if rc == nil {
+ return telegramCommandReply{Text: errMsg}
+ }
+ if s.auth == nil {
+ return telegramCommandReply{Text: "注册服务暂不可用。"}
+ }
+ if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil {
+ if u, _ := s.repo.User.FindByID(ctx, binding.UserID); u != nil {
+ return telegramCommandReply{Text: fmt.Sprintf("当前 Telegram 已绑定账号 %s,无需再用注册码。", u.Username)}
+ }
+ }
+ // Generate a memorable default account from the code; users can rename via
+ //「改用户名/改密码」afterwards. We avoid asking for two more text turns here.
+ username := "u" + strings.ToLower(rc.Code[:8])
+ password := randomCode(10)
+ user, _, err := s.auth.Register(ctx, username, password)
+ if err != nil {
+ return telegramCommandReply{Text: "注册失败:" + err.Error()}
+ }
+ if err := s.repo.RegCode.MarkUsed(ctx, rc.ID, user.ID); err != nil {
+ // Code was raced; roll back the just-created account to avoid free signups.
+ _ = s.repo.User.Delete(ctx, user.ID)
+ return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"}
+ }
+ if rc.DurationDays > 0 {
+ _ = s.applyRenewal(ctx, user.ID, rc.DurationDays)
+ }
+ _ = s.upsertTelegramBinding(ctx, msg, user.ID)
+ return telegramCommandReply{
+ Text: fmt.Sprintf("兑换成功并已创建账号:\n用户名:%s\n密码:%s\n到期:%s\n\n请尽快用「改用户名/改密码」修改为你自己的凭据。",
+ username, password, formatExpiry(s.userExpiry(ctx, user.ID))),
+ Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}},
+ }
+}
+
+func (s *TelegramBotService) redeemRenewFlow(ctx context.Context, msg *TelegramMessage, raw string) telegramCommandReply {
+ user := s.boundUser(ctx, msg.From.ID)
+ if user == nil {
+ return telegramCommandReply{Text: "请先绑定账号再续期。"}
+ }
+ rc, errMsg := s.lookupRedeemableCode(ctx, raw, model.RegistrationCodeRenew)
+ if rc == nil {
+ return telegramCommandReply{Text: errMsg}
+ }
+ if err := s.repo.RegCode.MarkUsed(ctx, rc.ID, user.ID); err != nil {
+ return telegramCommandReply{Text: "兑换码刚刚被使用,请换一个。"}
+ }
+ if err := s.applyRenewal(ctx, user.ID, rc.DurationDays); err != nil {
+ return telegramCommandReply{Text: "续期失败:" + err.Error()}
+ }
+ return telegramCommandReply{Text: fmt.Sprintf("续期成功 ✅ 当前到期:%s", formatExpiry(s.userExpiry(ctx, user.ID)))}
+}
+
+func (s *TelegramBotService) userExpiry(ctx context.Context, userID string) *time.Time {
+ if u, _ := s.repo.User.FindByID(ctx, userID); u != nil {
+ return u.ExpiredAt
+ }
+ return nil
+}
+
+// ── 管理员:容量 / 开注 / 兑换码 / 用户管理 / 设备策略 ─────────────────────────
+
+func (s *TelegramBotService) replyCapacity(ctx context.Context) telegramCommandReply {
+ c := s.loadCapacity(ctx)
+ quota := "未开放"
+ if c.OpenRegOn {
+ if c.OpenRegLimit > 0 {
+ quota = fmt.Sprintf("已开放(%d/%d 名额)", c.OpenRegUsed, c.OpenRegLimit)
+ } else {
+ quota = "已开放(不限名额,受授权上限约束)"
+ }
+ }
+ text := fmt.Sprintf("容量 / 状态\n\n授权上限:%d 人(随凭证授权实时变化)\n已用:%d 人\n剩余可注册:%d 人\n开注状态:%s",
+ c.MaxUsers, c.UsedUsers, c.Remaining(), quota)
+ return telegramCommandReply{Text: text, Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回菜单", Data: "menu_main"}}}}
+}
+
+func (s *TelegramBotService) replyOpenRegMenu(ctx context.Context) telegramCommandReply {
+ c := s.loadCapacity(ctx)
+ state := "未开放"
+ if c.OpenRegOn {
+ state = fmt.Sprintf("已开放(%d/%d)", c.OpenRegUsed, c.OpenRegLimit)
+ }
+ return telegramCommandReply{
+ Text: "开注设置\n当前:" + state + "\n选择要开放的名额:",
+ Buttons: [][]telegramInlineButton{
+ {{Text: "5 个", Data: "adm_openreg_set:5"}, {Text: "10 个", Data: "adm_openreg_set:10"}, {Text: "20 个", Data: "adm_openreg_set:20"}},
+ {{Text: "不限名额", Data: "adm_openreg_set:0"}, {Text: "关闭注册", Data: "adm_openreg_close"}},
+ {{Text: "⬅️ 返回菜单", Data: "menu_main"}},
+ },
+ }
+}
+
+func (s *TelegramBotService) replyGenCodeMenu() telegramCommandReply {
+ return telegramCommandReply{
+ Text: "生成兑换码\n选择类型与时长:",
+ Buttons: [][]telegramInlineButton{
+ {{Text: "注册码·30天", Data: "gc:register:30"}, {Text: "注册码·永久", Data: "gc:register:0"}},
+ {{Text: "续期码·30天", Data: "gc:renew:30"}, {Text: "续期码·90天", Data: "gc:renew:90"}},
+ {{Text: "⬅️ 返回菜单", Data: "menu_main"}},
+ },
+ }
+}
+
+func (s *TelegramBotService) replyGenCode(ctx context.Context, msg *TelegramMessage, data string) telegramCommandReply {
+ parts := strings.Split(data, ":") // gc:%s\n\n发给用户在 Bot 中兑换即可。", kindLabel, dur, code.Code),
+ Buttons: [][]telegramInlineButton{{{Text: "再生成一个", Data: "adm_gencode"}, {Text: "⬅️ 返回菜单", Data: "menu_main"}}},
+ }
+}
+
+func (s *TelegramBotService) replyUserList(ctx context.Context) telegramCommandReply {
+ users, err := s.repo.User.List(ctx)
+ if err != nil {
+ return telegramCommandReply{Text: "读取用户失败:" + err.Error()}
+ }
+ if len(users) == 0 {
+ return telegramCommandReply{Text: "暂无用户。"}
+ }
+ var rows [][]telegramInlineButton
+ limit := len(users)
+ if limit > 12 {
+ limit = 12
+ }
+ for i := 0; i < limit; i++ {
+ u := users[i]
+ flag := ""
+ if !u.IsActive {
+ flag = "🚫"
+ }
+ if u.Role == "admin" {
+ flag = "👑"
+ }
+ rows = append(rows, []telegramInlineButton{{Text: flag + " " + u.Username, Data: "usr:" + u.ID}})
+ }
+ rows = append(rows, []telegramInlineButton{{Text: "⬅️ 返回菜单", Data: "menu_main"}})
+ return telegramCommandReply{Text: fmt.Sprintf("用户管理(共 %d 人,显示前 %d)\n点击用户进行操作:", len(users), limit), Buttons: rows}
+}
+
+func (s *TelegramBotService) replyUserActions(ctx context.Context, userID string) telegramCommandReply {
+ u, err := s.repo.User.FindByID(ctx, userID)
+ if err != nil || u == nil {
+ return telegramCommandReply{Text: "用户不存在。"}
+ }
+ protected := u.Role == "admin"
+ if first, _ := s.repo.User.FirstAdmin(ctx); first != nil && first.ID == u.ID {
+ protected = true
+ }
+ text := fmt.Sprintf("%s\n角色:%s\n状态:%s\n到期:%s\n防共享警告:%d 次",
+ u.Username, u.Role, map[bool]string{true: "正常", false: "已禁用"}[u.IsActive], formatExpiry(u.ExpiredAt), u.ShareWarnings)
+ if protected {
+ return telegramCommandReply{Text: text + "\n\n(受保护账号,不可禁用/删除)", Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}}
+ }
+ banBtn := telegramInlineButton{Text: "🚫 禁用", Data: "uban:" + u.ID}
+ if !u.IsActive {
+ banBtn = telegramInlineButton{Text: "✅ 解禁", Data: "uunban:" + u.ID}
+ }
+ return telegramCommandReply{
+ Text: text,
+ Buttons: [][]telegramInlineButton{
+ {banBtn, {Text: "⏳ 续期30天", Data: "urenew:" + u.ID + ":30"}},
+ {{Text: "🗑 删除用户", Data: "udel:" + u.ID}},
+ {{Text: "⬅️ 返回", Data: "adm_users"}},
+ },
+ }
+}
+
+func (s *TelegramBotService) replyUserBan(ctx context.Context, userID string, unban bool) telegramCommandReply {
+ if !unban {
+ if reason := s.protectReason(ctx, userID); reason != "" {
+ return telegramCommandReply{Text: reason}
+ }
+ }
+ if err := s.repo.User.UpdateFields(ctx, userID, map[string]any{"is_active": unban}); err != nil {
+ return telegramCommandReply{Text: "操作失败:" + err.Error()}
+ }
+ return s.replyUserActions(ctx, userID)
+}
+
+func (s *TelegramBotService) replyUserDelete(ctx context.Context, userID string) telegramCommandReply {
+ if reason := s.protectReason(ctx, userID); reason != "" {
+ return telegramCommandReply{Text: reason}
+ }
+ u, _ := s.repo.User.FindByID(ctx, userID)
+ _ = s.repo.UserDevice.DeleteByUser(ctx, userID)
+ if err := s.repo.User.Delete(ctx, userID); err != nil {
+ return telegramCommandReply{Text: "删除失败:" + err.Error()}
+ }
+ name := userID
+ if u != nil {
+ name = u.Username
+ }
+ return telegramCommandReply{Text: fmt.Sprintf("已删除用户 %s。", name), Buttons: [][]telegramInlineButton{{{Text: "⬅️ 返回", Data: "adm_users"}}}}
+}
+
+func (s *TelegramBotService) replyUserRenew(ctx context.Context, payload string) telegramCommandReply {
+ parts := strings.Split(payload, ":") // - 整理是「从源目录整理到目的地目录」:源目录是待整理文件当前所在的位置,目的地目录是整理后输出的位置。两者留空则分别沿用设置页的默认值(默认源目录 = 媒体库路径)。 + 整理是「从源目录整理到目的地目录」:源目录是待整理文件当前所在的位置,目的地目录是整理后输出的位置。两者留空则分别沿用上方默认设置(默认源目录 = 媒体库路径)。