diff --git a/README.md b/README.md
index d0b205e..b491026 100644
--- a/README.md
+++ b/README.md
@@ -238,7 +238,7 @@ mkdir -p data cache media downloads
```bash
cat > .env <<'EOF'
# 固定版本;需要升级时改成新的 MediaStationGo-vX.Y.Z 后执行 docker compose pull && docker compose up -d
-MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
+MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
MEDIASTATION_HTTP_PORT=18080
# 程序数据和缓存建议放在 MediaStationGo 部署目录下,便于备份和迁移。
@@ -307,7 +307,7 @@ vim docker-compose.yml
#
# 镜像版本:
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
-# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
+# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
#
# 路径映射总览:
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
@@ -516,7 +516,7 @@ docker compose up -d
```bash
cat > .env <<'EOF'
-MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
+MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
MEDIASTATION_HTTP_PORT=18080
MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache
@@ -779,26 +779,26 @@ cd MediaStationGo
| 平台 | 包名示例 |
| --- | --- |
-| Linux x86_64 | `MediaStationGo-v0.0.27-linux-amd64.tar.gz` |
-| Linux ARM64 | `MediaStationGo-v0.0.27-linux-arm64.tar.gz` |
-| Windows x86_64 | `MediaStationGo-v0.0.27-windows-amd64.zip` |
-| macOS Intel | `MediaStationGo-v0.0.27-darwin-amd64.tar.gz` |
-| macOS Apple Silicon | `MediaStationGo-v0.0.27-darwin-arm64.tar.gz` |
+| Linux x86_64 | `MediaStationGo-v0.0.28-linux-amd64.tar.gz` |
+| Linux ARM64 | `MediaStationGo-v0.0.28-linux-arm64.tar.gz` |
+| Windows x86_64 | `MediaStationGo-v0.0.28-windows-amd64.zip` |
+| macOS Intel | `MediaStationGo-v0.0.28-darwin-amd64.tar.gz` |
+| macOS Apple Silicon | `MediaStationGo-v0.0.28-darwin-arm64.tar.gz` |
部署步骤:
```bash
# Linux 示例
-tar -xzf MediaStationGo-v0.0.27-linux-amd64.tar.gz
-cd MediaStationGo-v0.0.27-linux-amd64
+tar -xzf MediaStationGo-v0.0.28-linux-amd64.tar.gz
+cd MediaStationGo-v0.0.28-linux-amd64
MEDIASTATION_APP_PORT=18080 ./mediastation-go
```
Windows:
```powershell
-Expand-Archive .\MediaStationGo-v0.0.27-windows-amd64.zip
-cd .\MediaStationGo-v0.0.27-windows-amd64
+Expand-Archive .\MediaStationGo-v0.0.28-windows-amd64.zip
+cd .\MediaStationGo-v0.0.28-windows-amd64
$env:MEDIASTATION_APP_PORT = "18080"
.\mediastation-go.exe
```
diff --git a/README_EN.md b/README_EN.md
index c8a6ea8..d341f34 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -235,7 +235,7 @@ mkdir -p data cache media downloads
```bash
cat > .env <<'EOF'
-MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
+MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
MEDIASTATION_HTTP_PORT=18080
MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache
@@ -344,7 +344,7 @@ For production, pin a specific release tag instead of using `latest`. Recommende
```bash
cat > .env <<'EOF'
-MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
+MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
MEDIASTATION_HTTP_PORT=18080
MEDIASTATION_DATA_DIR=./data
MEDIASTATION_CACHE_DIR=./cache
@@ -593,25 +593,25 @@ Each release provides multi-platform archives:
| Platform | Package example |
| --- | --- |
-| Linux x86_64 | `MediaStationGo-v0.0.27-linux-amd64.tar.gz` |
-| Linux ARM64 | `MediaStationGo-v0.0.27-linux-arm64.tar.gz` |
-| Windows x86_64 | `MediaStationGo-v0.0.27-windows-amd64.zip` |
-| macOS Intel | `MediaStationGo-v0.0.27-darwin-amd64.tar.gz` |
-| macOS Apple Silicon | `MediaStationGo-v0.0.27-darwin-arm64.tar.gz` |
+| Linux x86_64 | `MediaStationGo-v0.0.28-linux-amd64.tar.gz` |
+| Linux ARM64 | `MediaStationGo-v0.0.28-linux-arm64.tar.gz` |
+| Windows x86_64 | `MediaStationGo-v0.0.28-windows-amd64.zip` |
+| macOS Intel | `MediaStationGo-v0.0.28-darwin-amd64.tar.gz` |
+| macOS Apple Silicon | `MediaStationGo-v0.0.28-darwin-arm64.tar.gz` |
Linux example:
```bash
-tar -xzf MediaStationGo-v0.0.27-linux-amd64.tar.gz
-cd MediaStationGo-v0.0.27-linux-amd64
+tar -xzf MediaStationGo-v0.0.28-linux-amd64.tar.gz
+cd MediaStationGo-v0.0.28-linux-amd64
MEDIASTATION_APP_PORT=18080 ./mediastation-go
```
Windows example:
```powershell
-Expand-Archive .\MediaStationGo-v0.0.27-windows-amd64.zip
-cd .\MediaStationGo-v0.0.27-windows-amd64
+Expand-Archive .\MediaStationGo-v0.0.28-windows-amd64.zip
+cd .\MediaStationGo-v0.0.28-windows-amd64
$env:MEDIASTATION_APP_PORT = "18080"
.\mediastation-go.exe
```
diff --git a/docker-compose.yml b/docker-compose.yml
index c3056bc..845fecf 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -17,7 +17,7 @@
#
# 镜像版本:
# 默认拉取 latest;如需固定版本,创建 .env 并写入:
-# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.27
+# MEDIASTATION_IMAGE_TAG=MediaStationGo-v0.0.28
#
# 路径映射总览:
# /data 程序数据目录。保存 SQLite 数据库、JWT secret、系统配置等,必须持久化。
diff --git a/internal/handler/admin.go b/internal/handler/admin.go
index 99917b5..7938801 100644
--- a/internal/handler/admin.go
+++ b/internal/handler/admin.go
@@ -63,6 +63,10 @@ type adminUpdateUserReq struct {
Username string `json:"username" binding:"required"`
}
+type adminResetPasswordReq struct {
+ Password string `json:"password" binding:"required,min=6"`
+}
+
func updateUserHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
var req adminUpdateUserReq
@@ -132,6 +136,21 @@ func deleteUserHandler(svc *service.Container) gin.HandlerFunc {
}
}
+func resetUserPasswordHandler(svc *service.Container) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ var req adminResetPasswordReq
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if err := svc.Auth.ResetPassword(c.Request.Context(), c.Param("id"), req.Password); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+ }
+}
+
func annotateProtectedUsers(ctx context.Context, svc *service.Container, users []model.User) error {
firstAdmin, err := svc.Repo.User.FirstAdmin(ctx)
if err != nil || firstAdmin == nil {
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
index 8a712f7..ac3a997 100644
--- a/internal/handler/handler.go
+++ b/internal/handler/handler.go
@@ -280,6 +280,7 @@ func Register(r *gin.Engine, cfg *config.Config, log *zap.Logger, svc *service.C
admin.GET("/users", listUsersHandler(svc))
admin.POST("/users", createUserHandler(svc))
admin.PATCH("/users/:id", updateUserHandler(svc))
+ admin.PATCH("/users/:id/password", resetUserPasswordHandler(svc))
admin.PATCH("/users/:id/role", adminUpdateRoleHandler(svc))
admin.DELETE("/users/:id", deleteUserHandler(svc))
admin.GET("/settings", listSettingsHandler(svc))
diff --git a/internal/repository/repository.go b/internal/repository/repository.go
index 75fe4a3..e0f21e4 100644
--- a/internal/repository/repository.go
+++ b/internal/repository/repository.go
@@ -81,6 +81,27 @@ func (r *UserRepository) Create(ctx context.Context, u *model.User) error {
return r.db.WithContext(ctx).Create(u).Error
}
+// ReleaseDeletedUsername renames soft-deleted rows that still hold a unique
+// username so the same account name can be created again.
+func (r *UserRepository) ReleaseDeletedUsername(ctx context.Context, username string) error {
+ if username == "" {
+ return nil
+ }
+ released := username + "__deleted__" + time.Now().Format("20060102150405.000000000")
+ if len(released) > 64 {
+ sum := sha256.Sum256([]byte(released))
+ released = username
+ if len(released) > 43 {
+ released = released[:43]
+ }
+ released += "__deleted__" + hex.EncodeToString(sum[:])[:10]
+ }
+ return r.db.WithContext(ctx).Unscoped().
+ Model(&model.User{}).
+ Where("username = ? AND deleted_at IS NOT NULL", username).
+ Update("username", released).Error
+}
+
// FindByUsername returns the user matching username, or (nil, nil) when absent.
func (r *UserRepository) FindByUsername(ctx context.Context, username string) (*model.User, error) {
var u model.User
@@ -161,9 +182,31 @@ func (r *UserRepository) TouchLogin(ctx context.Context, id string) error {
Update("last_login_at", &now).Error
}
-// Delete removes a user (soft-delete via gorm.DeletedAt).
+// Delete removes a user (soft-delete via gorm.DeletedAt), releases the unique
+// username, and drops Telegram bindings so future re-created users bind cleanly.
func (r *UserRepository) Delete(ctx context.Context, id string) error {
- return r.db.WithContext(ctx).Delete(&model.User{}, "id = ?", id).Error
+ return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ var user model.User
+ if err := tx.Where("id = ?", id).First(&user).Error; err != nil {
+ return err
+ }
+ if err := tx.Unscoped().Where("user_id = ?", id).Delete(&model.TelegramBinding{}).Error; err != nil {
+ return err
+ }
+ released := user.Username + "__deleted__" + time.Now().Format("20060102150405.000000000")
+ if len(released) > 64 {
+ sum := sha256.Sum256([]byte(user.ID + user.Username))
+ base := user.Username
+ if len(base) > 43 {
+ base = base[:43]
+ }
+ released = base + "__deleted__" + hex.EncodeToString(sum[:])[:10]
+ }
+ if err := tx.Model(&model.User{}).Where("id = ?", id).Update("username", released).Error; err != nil {
+ return err
+ }
+ return tx.Delete(&model.User{}, "id = ?", id).Error
+ })
}
// ─── Library ─────────────────────────────────────────────────────────────────
diff --git a/internal/service/auth.go b/internal/service/auth.go
index 4ab1206..ae827fc 100644
--- a/internal/service/auth.go
+++ b/internal/service/auth.go
@@ -95,6 +95,9 @@ func (s *AuthService) Register(ctx context.Context, username, password string) (
} else if existing != nil {
return nil, nil, ErrUsernameTaken
}
+ if err := s.repo.User.ReleaseDeletedUsername(ctx, username); err != nil {
+ return nil, nil, err
+ }
if n, err := s.repo.User.Count(ctx); err != nil {
return nil, nil, err
} else if n >= LicensedMaxUsers(ctx, s.repo) {
@@ -181,6 +184,29 @@ func (s *AuthService) ChangePassword(ctx context.Context, userID, oldPwd, newPwd
return s.repo.User.UpdatePassword(ctx, userID, hash)
}
+// ResetPassword lets an administrator set a new password without knowing the
+// user's old password.
+func (s *AuthService) ResetPassword(ctx context.Context, userID, newPwd string) error {
+ if strings.TrimSpace(userID) == "" {
+ return errors.New("missing user id")
+ }
+ if strings.TrimSpace(newPwd) == "" || len(newPwd) < 6 {
+ return errors.New("new password must be at least 6 characters")
+ }
+ u, err := s.repo.User.FindByID(ctx, userID)
+ if err != nil {
+ return err
+ }
+ if u == nil {
+ return errors.New("user not found")
+ }
+ hash, err := hashPassword(newPwd)
+ if err != nil {
+ return err
+ }
+ return s.repo.User.UpdatePassword(ctx, userID, hash)
+}
+
// VerifyPassword checks a user's current password without mutating account
// state. It is used for sensitive self-service actions such as hiding adult
// libraries or deleting play profiles.
diff --git a/internal/service/auth_user_limits_test.go b/internal/service/auth_user_limits_test.go
index 154588f..370b1e7 100644
--- a/internal/service/auth_user_limits_test.go
+++ b/internal/service/auth_user_limits_test.go
@@ -21,7 +21,7 @@ func newAuthTestServices(t *testing.T) (*repository.Container, *AuthService, *Pr
if err != nil {
t.Fatal(err)
}
- if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}); err != nil {
+ if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.TelegramBinding{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
@@ -66,6 +66,87 @@ func TestRegisterDefaultsAdultLibrariesHidden(t *testing.T) {
}
}
+func TestDeletedUserCanBeRecreatedWithSameUsername(t *testing.T) {
+ ctx := context.Background()
+ repos, auth, _, _ := newAuthTestServices(t)
+ user, _, err := auth.Register(ctx, "viewer", "old-password")
+ if err != nil {
+ t.Fatalf("register old user: %v", err)
+ }
+ if err := repos.DB.Create(&model.TelegramBinding{
+ TelegramUserID: 10001,
+ TelegramName: "@viewer",
+ ChatID: 10001,
+ UserID: user.ID,
+ }).Error; err != nil {
+ t.Fatalf("create telegram binding: %v", err)
+ }
+ if err := repos.User.Delete(ctx, user.ID); err != nil {
+ t.Fatalf("delete user: %v", err)
+ }
+
+ next, _, err := auth.Register(ctx, "viewer", "new-password")
+ if err != nil {
+ t.Fatalf("register same username after delete: %v", err)
+ }
+ if next.ID == user.ID {
+ t.Fatal("recreated user should be a new account row")
+ }
+ if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil {
+ t.Fatalf("login recreated user: %v", err)
+ }
+ var bindings int64
+ if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 10001).Count(&bindings).Error; err != nil {
+ t.Fatalf("count bindings: %v", err)
+ }
+ if bindings != 0 {
+ t.Fatalf("deleted user telegram bindings should be removed, got %d", bindings)
+ }
+}
+
+func TestRegisterReleasesLegacySoftDeletedUsername(t *testing.T) {
+ ctx := context.Background()
+ repos, auth, _, _ := newAuthTestServices(t)
+ if err := repos.User.Create(ctx, &model.User{
+ Username: "legacy",
+ PasswordHash: "hash",
+ Role: "user",
+ Tier: "free",
+ }); err != nil {
+ t.Fatal(err)
+ }
+ legacy, err := repos.User.FindByUsername(ctx, "legacy")
+ if err != nil || legacy == nil {
+ t.Fatalf("find legacy user: %v", err)
+ }
+ if err := repos.DB.Delete(&model.User{}, "id = ?", legacy.ID).Error; err != nil {
+ t.Fatalf("legacy soft delete: %v", err)
+ }
+
+ if _, _, err := auth.Register(ctx, "legacy", "new-password"); err != nil {
+ t.Fatalf("register should release old soft-deleted username: %v", err)
+ }
+}
+
+func TestAdminResetPasswordAllowsLoginWithNewPassword(t *testing.T) {
+ ctx := context.Background()
+ _, auth, _, _ := newAuthTestServices(t)
+ user, _, err := auth.Register(ctx, "viewer", "old-password")
+ if err != nil {
+ t.Fatalf("register: %v", err)
+ }
+
+ if err := auth.ResetPassword(ctx, user.ID, "new-password"); err != nil {
+ t.Fatalf("reset password: %v", err)
+ }
+ if _, err := auth.Login(ctx, "viewer", "old-password"); !errors.Is(err, ErrInvalidCredentials) {
+ t.Fatalf("old password should fail, got %v", err)
+ }
+ if _, err := auth.Login(ctx, "viewer", "new-password"); err != nil {
+ t.Fatalf("new password should login: %v", err)
+ }
+}
+
func TestDefaultPermissionsAreViewerOnly(t *testing.T) {
perms := DefaultPermissions("user-1")
if !perms.CanViewDashboard || !perms.CanPlayMedia || !perms.CanExternalPlayer {
diff --git a/internal/service/telegram_bot.go b/internal/service/telegram_bot.go
index 52bf8f2..e8f0951 100644
--- a/internal/service/telegram_bot.go
+++ b/internal/service/telegram_bot.go
@@ -231,14 +231,18 @@ func (s *TelegramBotService) cmdStart(ctx context.Context, msg *TelegramMessage,
if len(args) == 0 {
if binding := s.telegramBinding(ctx, msg.From.ID); binding != nil {
user, _ := s.repo.User.FindByID(ctx, binding.UserID)
+ if user == nil {
+ _ = s.repo.DB.WithContext(ctx).Unscoped().Delete(&model.TelegramBinding{}, "id = ?", binding.ID).Error
+ return telegramCommandReply{Text: "之前绑定的媒体中心账号已不存在,请重新绑定:\n/start 用户名 密码"}
+ }
status := "未隐藏"
- if user != nil && user.HideAdult {
+ if user.HideAdult {
status = "已隐藏"
}
return telegramCommandReply{
Text: fmt.Sprintf("MediaStationGo 已绑定\n\n你好 %s,当前账号:%s\n成人目录:%s", name, userNameOrFallback(user), status),
Buttons: [][]telegramInlineButton{{{
- Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user != nil && user.HideAdult],
+ Text: map[bool]string{true: "显示成人目录", false: "隐藏成人目录"}[user.HideAdult],
Data: "adult_toggle",
}}},
}
@@ -895,6 +899,9 @@ func (s *TelegramBotService) upsertTelegramBinding(ctx context.Context, msg *Tel
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
+ if err := s.repo.DB.WithContext(ctx).Unscoped().Where("telegram_user_id = ?", int64(msg.From.ID)).Delete(&model.TelegramBinding{}).Error; err != nil {
+ return err
+ }
return s.repo.DB.WithContext(ctx).Create(&model.TelegramBinding{
TelegramUserID: int64(msg.From.ID),
TelegramName: name,
diff --git a/internal/service/telegram_bot_user_test.go b/internal/service/telegram_bot_user_test.go
new file mode 100644
index 0000000..f0dc1b9
--- /dev/null
+++ b/internal/service/telegram_bot_user_test.go
@@ -0,0 +1,39 @@
+package service
+
+import (
+ "strings"
+ "testing"
+
+ "go.uber.org/zap"
+
+ "github.com/ShukeBta/MediaStationGo/internal/model"
+)
+
+func TestTelegramStartClearsStaleUserBinding(t *testing.T) {
+ repos, _, _, _ := newAuthTestServices(t)
+ if err := repos.DB.Create(&model.TelegramBinding{
+ TelegramUserID: 20001,
+ TelegramName: "@viewer",
+ ChatID: 20001,
+ UserID: "deleted-user",
+ }).Error; err != nil {
+ t.Fatalf("create binding: %v", err)
+ }
+ bot := NewTelegramBotService(zap.NewNop(), repos, nil)
+
+ reply := bot.cmdStart(t.Context(), &TelegramMessage{
+ From: TelegramUser{ID: 20001, Username: "viewer", FirstName: "Viewer"},
+ Chat: TelegramChat{ID: 20001, Type: "private"},
+ }, nil)
+
+ if !strings.Contains(reply.Text, "已不存在") {
+ t.Fatalf("expected stale binding message, got %q", reply.Text)
+ }
+ var count int64
+ if err := repos.DB.Model(&model.TelegramBinding{}).Where("telegram_user_id = ?", 20001).Count(&count).Error; err != nil {
+ t.Fatalf("count binding: %v", err)
+ }
+ if count != 0 {
+ t.Fatalf("stale binding should be removed, got %d", count)
+ }
+}
diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts
index 3a9e35e..c8ef449 100644
--- a/web/src/api/admin.ts
+++ b/web/src/api/admin.ts
@@ -10,6 +10,9 @@ export const adminAPI = {
updateUser: (id: string, payload: { username: string }) =>
api.patch(`/admin/users/${id}`, payload).then((r) => r.data),
+ resetUserPassword: (id: string, password: string) =>
+ api.patch(`/admin/users/${id}/password`, { password }).then((r) => r.data),
+
deleteUser: (id: string) => api.delete(`/admin/users/${id}`).then((r) => r.data),
listSettings: () => api.get('/admin/settings').then((r) => r.data),
diff --git a/web/src/pages/AdminPage.tsx b/web/src/pages/AdminPage.tsx
index e41bc45..f81f8e9 100644
--- a/web/src/pages/AdminPage.tsx
+++ b/web/src/pages/AdminPage.tsx
@@ -1,6 +1,6 @@
import { FormEvent, useEffect, useState } from 'react'
import toast from 'react-hot-toast'
-import { Pencil, Plus, ShieldCheck, Trash2, X } from 'lucide-react'
+import { KeyRound, Pencil, Plus, ShieldCheck, Trash2, X } from 'lucide-react'
import { adminAPI } from '../api/admin'
import { libraryAPI } from '../api/library'
@@ -8,6 +8,7 @@ import type { Library, User } from '../types'
import { APIConfigsPanel } from '../components/APIConfigsPanel'
import { ManagementShortcuts } from '../components/ManagementShortcuts'
import { confirmAction } from '../components/ConfirmDialog'
+import { requestPassword } from '../components/PasswordDialog'
export function AdminPage() {
const [tab, setTab] = useState<'library' | 'users' | 'api'>('library')
@@ -206,6 +207,28 @@ function UsersPanel() {
}
}
+ const resetPassword = async (u: User) => {
+ const nextPassword = await requestPassword({
+ title: `重置 ${u.username} 的密码`,
+ message: '请输入新的临时密码,至少 6 位。保存后该用户可立即使用新密码登录 Web、Bot 与第三方客户端。',
+ confirmText: '重置密码',
+ })
+ if (!nextPassword) return
+ if (nextPassword.length < 6) {
+ toast.error('新密码至少 6 位')
+ return
+ }
+ try {
+ await adminAPI.resetUserPassword(u.id, nextPassword)
+ toast.success('密码已重置')
+ } catch (err: unknown) {
+ const msg =
+ (err as { response?: { data?: { error?: string } } })?.response?.data?.error ??
+ '重置密码失败'
+ toast.error(msg)
+ }
+ }
+
return (