Compare commits

...

20 Commits

Author SHA1 Message Date
282e1e4087 deviceno
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m26s
2026-03-17 09:12:56 -07:00
4b528184ea devicd_no
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m58s
2026-03-17 08:37:36 -07:00
dcdbabdb13 注销
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m34s
2026-03-17 07:12:42 -07:00
3cd22d8538 x
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 8m3s
2026-03-16 00:59:49 -07:00
1d2f2a594a feat: 增强用户搜索功能,支持通过设备ID哈希进行查找。
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 8m42s
2026-03-15 22:02:23 -07:00
79a1eeca1f feat: 自动生成并更新用户缺失的推荐码
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m33s
2026-03-15 19:43:53 -07:00
3d732c0361 feat: 为用户设备添加 DeviceNo 字段并实现 ID 混淆,同时在迁移脚本中新增 ID 重建逻辑。
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-03-15 19:38:37 -07:00
3db14ae472 feat: 迁移订阅表字段,为用户认证方法添加软删除功能及索引,并调整 Docker Compose 中 MySQL 端口映射。
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m34s
2026-03-15 01:47:01 -07:00
ad578883e4 feat: 添加付费用户数据迁移脚本、报告及相关管理逻辑调整。
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m49s
2026-03-14 22:37:03 -07:00
2cc1124dd8 fix: 任何人注销账号都解散整个家庭组,删除所有成员的登录方式和订阅
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m41s
统一 member/owner 注销逻辑:无论谁注销,都解散家庭、
删除所有成员的 AuthMethods + Subscribe、踢出所有设备、清除所有缓存。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-12 06:23:11 -07:00
b6405c8f28 fix: IAP attach 后立即清除订阅缓存,修复 App 查到旧数据;返回值改用累计到期时间
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m33s
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-12 04:56:17 -07:00
add27aa4d9 fix: Apple IAP attach 支持家庭成员购买场景
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m15s
member 发起购买后订阅归属于 owner,但 attach 交易时校验
orderSub.UserId != u.Id 报"订单订阅与当前用户不匹配"。
现在通过 ResolveEntitlementUser 获取 EffectiveUserID,
允许 member 绑定属于其家庭 owner 的订阅。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-12 03:38:42 -07:00
2f33e1e680 fix: 踢其他设备时不再清除踢人者自己的 session
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m29s
logoutUnbind 循环清理家庭成员时,踢人者(kicker)也在成员列表中,
导致 kicker 的设备被踢、session 被清,自己也下线了。
现在跳过 kickerUserID,只清缓存不踢设备不清 session。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-12 03:05:34 -07:00
e4ec85c176 fix: clearAllSessions 改用 SCAN 查找 session,修复会话清理无效
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m37s
根因:登录时只写了 auth:session_id:{sessionId} (Redis SET),
从未写入 auth:user_sessions:{userId} sorted set,
导致 clearAllSessions 用 ZRange 永远返回空,session 根本没被清除。

修复:改用 SCAN auth:session_id:* 遍历所有 session key,
按 value 匹配 userId 找出该用户的全部 session 后删除,
同时清理关联的 device cache key。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-12 02:47:32 -07:00
dddb8c7fe2 fix: 平级踢人后更新被踢设备 online 状态为 false
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
unbindDeviceLogic.logoutUnbind 中 KickDevice 后缺少
device.Online = false + UpdateDevice,导致后台仍显示在线

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-12 02:37:12 -07:00
384c8df506 fix: 踢出用户时清除所有 session,确保旧 token 立即失效
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m43s
- kickOfflineByUserDeviceLogic: 管理员踢设备后新增 clearAllSessions,
  之前只清单个 WebSocket session,用户可用旧 token 继续访问
- unbindDeviceLogic: 家庭成员被踢时增加踢设备+清 session;
  补全 session detail key 清理

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-12 02:19:35 -07:00
9b6efe2901 DefaultFamilyMaxSize
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m32s
2026-03-12 01:41:39 -07:00
7d5b4fcb84 邮箱修复
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 7m13s
2026-03-12 01:24:21 -07:00
a3cc23bbd4 feat: 绑定新邮箱时创建独立邮箱用户并转移订阅,而非挂在设备用户上
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 8m5s
- bindEmailWithVerificationLogic: 新邮箱路径改为创建独立 email user + joinFamily
- familyBindingHelper: clearMemberSubscribes → transferMemberSubscribesToOwner,订阅转移给 owner 而非删除
- accountMergeHelper: 同步更新调用点

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-12 00:52:50 -07:00
ec39579c39 fix: 注册 /v1/common/client/download 路由,修复客户端下载链接 404
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 8m8s
handler 和 logic 代码已迁移但 routes.go 漏注册该端点。

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-11 22:34:03 -07:00
40 changed files with 8718 additions and 193 deletions

View File

@ -545,6 +545,7 @@ type (
Id int64 `json:"id"`
Ip string `json:"ip"`
Identifier string `json:"identifier"`
DeviceNo string `json:"device_no"`
UserAgent string `json:"user_agent"`
Online bool `json:"online"`
Enabled bool `json:"enabled"`
@ -564,6 +565,7 @@ type (
FamilyMemberItem {
UserId int64 `json:"user_id"`
Identifier string `json:"identifier"`
DeviceNo string `json:"device_no"`
Role uint8 `json:"role"`
RoleName string `json:"role_name"`
Status uint8 `json:"status"`

View File

@ -57,7 +57,7 @@ services:
container_name: ppanel-mysql
restart: always
ports:
- "127.0.0.1:3306:3306" # 仅宿主机可访问ppanel-server(host网络)通过127.0.0.1连接
- "3306:3306" # 仅宿主机可访问ppanel-server(host网络)通过127.0.0.1连接
environment:
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?请在 .env 文件中设置 MYSQL_ROOT_PASSWORD}"
MYSQL_DATABASE: "ppanel"

View File

@ -61,6 +61,10 @@ Trace: # 链路追踪配置 (OpenTelemetry)
Batcher: otlpgrpc # 本地开发留空""; 生产填 otlpgrpc
Endpoint: "127.0.0.1:4317" # host 网络模式; bridge 模式改为 tempo:4317
device:
enable: true # 开启设备加密通信
security_secret: "" # AES加密密钥需要和App端一致key=SHA256(security_secret)[:32]
Administrator:
Email: admin@ppanel.dev # 后台登录邮箱,请修改
Password: CHANGE_ME_TO_STRONG_PASSWORD # 后台登录密码,请修改为强密码

View File

@ -551,5 +551,37 @@ CREATE TABLE IF NOT EXISTS `server_rule_group`
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_family`
(
`id` bigint NOT NULL AUTO_INCREMENT,
`owner_user_id` bigint NOT NULL COMMENT 'Owner User ID',
`max_members` int NOT NULL DEFAULT 5 COMMENT 'Max members in family',
`status` tinyint NOT NULL DEFAULT 1 COMMENT 'Status: 1=active, 0=disabled',
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`deleted_at` datetime(3) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uniq_owner_user_id`(`owner_user_id` ASC) USING BTREE,
INDEX `idx_status`(`status` ASC) USING BTREE,
INDEX `idx_deleted_at`(`deleted_at` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 485 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS `user_family_member` (
`id` bigint NOT NULL AUTO_INCREMENT,
`family_id` bigint NOT NULL COMMENT 'Family ID',
`user_id` bigint NOT NULL COMMENT 'Member User ID',
`role` tinyint NOT NULL DEFAULT 2 COMMENT 'Role: 1=owner, 2=member',
`status` tinyint NOT NULL DEFAULT 1 COMMENT 'Status: 1=active, 2=left, 3=removed',
`join_source` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT '' COMMENT 'Join source',
`joined_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`left_at` datetime(3) NULL DEFAULT NULL,
`created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`deleted_at` datetime(3) NULL DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `uniq_user_id`(`user_id` ASC) USING BTREE,
INDEX `idx_family_status`(`family_id` ASC, `status` ASC) USING BTREE,
INDEX `idx_deleted_at`(`deleted_at` ASC) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 510 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci;
SET FOREIGN_KEY_CHECKS = 1;

View File

@ -1,7 +1,53 @@
ALTER TABLE `subscribe`
ADD COLUMN `nodes` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Node IDs',
ADD COLUMN `node_tags` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Node Tags',
DROP COLUMN `server`,
DROP COLUMN `server_group`;
-- Add nodes column if not exists
SET @col_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'subscribe'
AND COLUMN_NAME = 'nodes');
SET @sql = IF(@col_exists = 0,
'ALTER TABLE `subscribe` ADD COLUMN `nodes` VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''Node IDs''',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Add node_tags column if not exists
SET @col_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'subscribe'
AND COLUMN_NAME = 'node_tags');
SET @sql = IF(@col_exists = 0,
'ALTER TABLE `subscribe` ADD COLUMN `node_tags` VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''Node Tags''',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Drop server column if exists
SET @col_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'subscribe'
AND COLUMN_NAME = 'server');
SET @sql = IF(@col_exists > 0,
'ALTER TABLE `subscribe` DROP COLUMN `server`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- Drop server_group column if exists
SET @col_exists = (SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'subscribe'
AND COLUMN_NAME = 'server_group');
SET @sql = IF(@col_exists > 0,
'ALTER TABLE `subscribe` DROP COLUMN `server_group`',
'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
DROP TABLE IF EXISTS `server_rule_group`;

View File

@ -121,6 +121,25 @@ func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error {
column: "status",
ddl: "ALTER TABLE `redemption_code` ADD COLUMN `status` TINYINT NOT NULL DEFAULT 1 COMMENT 'Status: 1=enabled, 0=disabled';",
},
{
table: "user_auth_methods",
column: "deleted_at",
ddl: "ALTER TABLE `user_auth_methods` ADD COLUMN `deleted_at` DATETIME(3) DEFAULT NULL COMMENT 'Deletion Time';",
},
}
// Index patches: ensure critical indexes exist
type schemaIndexPatch struct {
table string
index string
ddl string
}
indexPatches := []schemaIndexPatch{
{
table: "user_auth_methods",
index: "idx_user_deleted_at",
ddl: "CREATE INDEX `idx_user_deleted_at` ON `user_auth_methods` (`user_id`, `deleted_at`);",
},
}
for _, patch := range tablePatches {
@ -159,6 +178,27 @@ func EnsureSchemaCompatibility(ctx *svc.ServiceContext) error {
logger.Infof("[SchemaCompat] added missing column: %s.%s", patch.table, patch.column)
}
for _, patch := range indexPatches {
tblExists, err := tableExists(ctx.DB, patch.table)
if err != nil {
return errors.Wrapf(err, "check table %s failed", patch.table)
}
if !tblExists {
continue
}
exists, err := indexExists(ctx.DB, patch.table, patch.index)
if err != nil {
return errors.Wrapf(err, "check index %s.%s failed", patch.table, patch.index)
}
if exists {
continue
}
if err = ctx.DB.Exec(patch.ddl).Error; err != nil {
return errors.Wrapf(err, "create index %s.%s failed", patch.table, patch.index)
}
logger.Infof("[SchemaCompat] created missing index: %s.%s", patch.table, patch.index)
}
return nil
}
@ -184,6 +224,19 @@ func columnExists(db *gorm.DB, table, column string) (bool, error) {
return count > 0, nil
}
func indexExists(db *gorm.DB, table, index string) (bool, error) {
var count int64
err := db.Raw(
"SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?",
table,
index,
).Scan(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func _schemaCompatDebug(table, column string) string {
if column == "" {
return table

View File

@ -101,11 +101,12 @@ type EmailConfig struct {
EnableNotify bool `yaml:"enable_notify"`
EnableDomainSuffix bool `yaml:"enable_domain_suffix"`
DomainSuffixList string `yaml:"domain_suffix_list"`
VerifyEmailTemplate string `yaml:"verify_email_template"`
VerifyEmailTemplates map[string]string `yaml:"verify_email_templates"`
ExpirationEmailTemplate string `yaml:"expiration_email_template"`
MaintenanceEmailTemplate string `yaml:"maintenance_email_template"`
TrafficExceedEmailTemplate string `yaml:"traffic_exceed_email_template"`
VerifyEmailTemplate string `yaml:"verify_email_template"`
VerifyEmailTemplates map[string]string `yaml:"verify_email_templates"`
ExpirationEmailTemplate string `yaml:"expiration_email_template"`
MaintenanceEmailTemplate string `yaml:"maintenance_email_template"`
TrafficExceedEmailTemplate string `yaml:"traffic_exceed_email_template"`
DeleteAccountEmailTemplate string `yaml:"delete_account_email_template"`
}
type MobileConfig struct {

View File

@ -716,6 +716,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Get Client
commonGroupRouter.GET("/client", common.GetClientHandler(serverCtx))
// Get Download Link
commonGroupRouter.GET("/client/download", common.GetDownloadLinkHandler(serverCtx))
// Heartbeat
commonGroupRouter.GET("/heartbeat", common.HeartbeatHandler(serverCtx))

View File

@ -55,7 +55,7 @@ func (l *GetAdminUserInviteListLogic) GetAdminUserInviteList(req *types.GetAdmin
var rows []InvitedUser
err = l.svcCtx.DB.WithContext(l.ctx).
Table("user u").
Select("u.id, u.avatar, u.enable, UNIX_TIMESTAMP(u.created_at) as created_at, COALESCE((SELECT uam.auth_identifier FROM user_auth_methods uam WHERE uam.user_id = u.id AND uam.deleted_at IS NULL ORDER BY uam.id ASC LIMIT 1), '') as identifier").
Select("u.id, u.avatar, u.enable, UNIX_TIMESTAMP(u.created_at) as created_at, COALESCE((SELECT uam.auth_identifier FROM user_auth_methods uam WHERE uam.user_id = u.id ORDER BY uam.id ASC LIMIT 1), '') as identifier").
Where("u.referer_id = ? AND u.deleted_at IS NULL", req.UserId).
Order("u.created_at DESC").
Limit(req.Size).

View File

@ -8,6 +8,7 @@ import (
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
@ -62,6 +63,15 @@ func (l *GetFamilyDetailLogic) GetFamilyDetail(req *types.GetFamilyDetailRequest
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query family member identifiers failed")
}
// 查出所有成员的设备,构建 userId → deviceNo 映射
deviceNoMap := make(map[int64]string)
devices, _, _ := l.svcCtx.UserModel.QueryDeviceListByUserIds(l.ctx, userIDs)
for _, d := range devices {
if _, exists := deviceNoMap[d.UserId]; !exists {
deviceNoMap[d.UserId] = tool.DeviceIdToHash(d.Id)
}
}
memberItems := make([]types.FamilyMemberItem, 0, len(members))
for _, member := range members {
identifier := identifierMap[member.UserId]
@ -72,6 +82,7 @@ func (l *GetFamilyDetailLogic) GetFamilyDetail(req *types.GetFamilyDetailRequest
memberItem := types.FamilyMemberItem{
UserId: member.UserId,
Identifier: identifier,
DeviceNo : deviceNoMap[member.UserId],
Role: member.Role,
RoleName: mapFamilyRoleName(member.Role),
Status: member.Status,

View File

@ -35,6 +35,11 @@ func (l *GetUserDetailLogic) GetUserDetail(req *types.GetDetailRequest) (*types.
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get user detail error: %v", err.Error())
}
tool.DeepCopy(&resp, userInfo)
for i, d := range userInfo.UserDevices {
if i < len(resp.UserDevices) {
resp.UserDevices[i].DeviceNo = tool.DeviceIdToHash(d.Id)
}
}
if referCode := strings.TrimSpace(resp.ReferCode); referCode != "" {
resp.ShareLink = logicCommon.NewInviteLinkResolver(l.ctx, l.svcCtx).ResolveInviteLink(referCode)
}

View File

@ -163,6 +163,13 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge
}
u.AuthMethods = authMethods
// 填充 DeviceID
for i, d := range item.UserDevices {
if i < len(u.UserDevices) {
u.UserDevices[i].DeviceNo = tool.DeviceIdToHash(d.Id)
}
}
if relation, ok := relationMap[item.Id]; ok {
u.FamilyJoined = true
u.FamilyId = relation.FamilyId

View File

@ -34,8 +34,14 @@ func (l *GetUserSubscribeDevicesLogic) GetUserSubscribeDevices(req *types.GetUse
}
userRespList := make([]types.UserDevice, 0)
tool.DeepCopy(&userRespList, list)
for i, d := range list {
if i < len(userRespList) {
userRespList[i].DeviceNo = tool.DeviceIdToHash(d.Id)
}
}
return &types.GetUserSubscribeDevicesResponse{
Total: total,
List: userRespList,
}, nil
}

View File

@ -2,7 +2,11 @@ package user
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
@ -38,5 +42,92 @@ func (l *KickOfflineByUserDeviceLogic) KickOfflineByUserDevice(req *types.KickOf
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update Device error: %v", err.Error())
}
// 清除该用户的所有会话,确保旧 token 失效
l.clearAllSessions(device.UserId)
return nil
}
// clearAllSessions 清除指定用户的所有会话(通过 SCAN 查找,不依赖 sorted set
func (l *KickOfflineByUserDeviceLogic) clearAllSessions(userId int64) {
sessionSet := make(map[string]struct{})
userIDText := strconv.FormatInt(userId, 10)
pattern := fmt.Sprintf("%s:*", config.SessionIdKey)
var cursor uint64
for {
keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, pattern, 200).Result()
if scanErr != nil {
l.Errorw("扫描会话键失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error()))
break
}
for _, sessionKey := range keys {
value, getErr := l.svcCtx.Redis.Get(l.ctx, sessionKey).Result()
if getErr != nil || value != userIDText {
continue
}
sessionID := strings.TrimPrefix(sessionKey, config.SessionIdKey+":")
if sessionID == "" || strings.HasPrefix(sessionID, "detail:") {
continue
}
sessionSet[sessionID] = struct{}{}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
deviceKeySet := make(map[string]struct{})
devicePattern := fmt.Sprintf("%s:*", config.DeviceCacheKeyKey)
cursor = 0
for {
keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, devicePattern, 200).Result()
if scanErr != nil {
l.Errorw("扫描设备会话映射失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error()))
break
}
for _, deviceKey := range keys {
sessionID, getErr := l.svcCtx.Redis.Get(l.ctx, deviceKey).Result()
if getErr != nil {
continue
}
if _, exists := sessionSet[sessionID]; exists {
deviceKeySet[deviceKey] = struct{}{}
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
if len(sessionSet) == 0 {
return
}
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
pipe := l.svcCtx.Redis.TxPipeline()
for sessionID := range sessionSet {
pipe.Del(l.ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID))
pipe.Del(l.ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionID))
pipe.ZRem(l.ctx, sessionsKey, sessionID)
}
pipe.Del(l.ctx, sessionsKey)
for deviceKey := range deviceKeySet {
pipe.Del(l.ctx, deviceKey)
}
if _, err := pipe.Exec(l.ctx); err != nil {
l.Errorw("清理会话缓存失败",
logger.Field("user_id", userId),
logger.Field("error", err.Error()),
)
}
l.Infow("[KickOffline] 管理员踢设备-清除所有Session",
logger.Field("user_id", userId),
logger.Field("count", len(sessionSet)),
)
}

View File

@ -89,7 +89,11 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty
// Generate verification code
code := random.Key(6, 0)
scene := constant.ParseVerifyType(req.Type).String()
taskPayload.Type = queue.EmailTypeVerify
if scene == constant.DeleteAccount.String() {
taskPayload.Type = queue.EmailTypeDeleteAccount
} else {
taskPayload.Type = queue.EmailTypeVerify
}
taskPayload.Scene = scene
taskPayload.Email = req.Email
taskPayload.Subject = "Verification code"

View File

@ -59,6 +59,13 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
l.Errorw("无效访问,用户信息缺失")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "invalid access")
}
// 解析家庭权益member 的订阅归属于 owner
entitlement, entErr := commonLogic.ResolveEntitlementUser(l.ctx, l.svcCtx.DB, u.Id)
if entErr != nil {
l.Errorw("解析家庭权益失败", logger.Field("userId", u.Id), logger.Field("error", entErr.Error()))
return nil, entErr
}
if strings.TrimSpace(req.OrderNo) == "" {
l.Errorw("参数错误orderNo 不能为空")
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "order_no is required")
@ -219,8 +226,8 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
orderSub, subErr := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, orderInfo.SubscribeToken)
switch {
case subErr == nil && orderSub != nil && orderSub.Id > 0:
if orderSub.UserId != u.Id {
l.Errorw("订单订阅与当前用户不匹配", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("orderSubUserId", orderSub.UserId), logger.Field("userId", u.Id))
if orderSub.UserId != u.Id && orderSub.UserId != entitlement.EffectiveUserID {
l.Errorw("订单订阅与当前用户不匹配", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("orderSubUserId", orderSub.UserId), logger.Field("userId", u.Id), logger.Field("effectiveUserId", entitlement.EffectiveUserID))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "order subscribe owner mismatch")
}
orderLinkedSub = orderSub
@ -233,7 +240,7 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
}
var singleModeAnchorSub *user.Subscribe
if !isNewPurchaseOrder && l.svcCtx.Config.Subscribe.SingleModel && orderLinkedSub == nil {
anchorSub, anchorErr := findSingleModeMergeTarget(l.ctx, l.svcCtx, u.Id, subscribeId)
anchorSub, anchorErr := findSingleModeMergeTarget(l.ctx, l.svcCtx, entitlement.EffectiveUserID, subscribeId)
switch {
case errors.Is(anchorErr, commonLogic.ErrSingleModePlanMismatch):
l.Errorw("单订阅模式下 IAP 套餐不匹配", logger.Field("userId", u.Id), logger.Field("orderNo", req.OrderNo), logger.Field("iapSubscribeId", subscribeId))
@ -385,7 +392,7 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
}
if !merged {
userSub := user.Subscribe{
UserId: u.Id,
UserId: entitlement.EffectiveUserID,
SubscribeId: subscribeId,
StartTime: time.Now(),
ExpireTime: exp,
@ -420,9 +427,37 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert error: %v", err.Error())
}
l.sendIAPAttachTraceToTelegram("SUCCESS_COMMIT", orderInfo, u.Id, subscribeId, tier, duration, txPayload.PurchaseDate, txPayload.TransactionId, txPayload.OriginalTransactionId, "")
l.Infow("绑定完成", logger.Field("userId", u.Id), logger.Field("tier", tier), logger.Field("expiresAt", exp.Unix()))
// 事务提交后立即清除订阅缓存,避免 App 查到旧数据(激活队列异步执行,存在竞态)
if orderLinkedSub != nil {
_ = l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, orderLinkedSub)
} else if singleModeAnchorSub != nil {
_ = l.svcCtx.UserModel.ClearSubscribeCache(l.ctx, singleModeAnchorSub)
}
// merged 路径下exp 仅从购买日算起,需要用已有订阅到期时间 + duration 作为预估值返回前端
responseExpire := exp
var mergeSub *user.Subscribe
if orderLinkedSub != nil {
mergeSub = orderLinkedSub
} else if singleModeAnchorSub != nil {
mergeSub = singleModeAnchorSub
}
if mergeSub != nil {
base := mergeSub.ExpireTime
now := time.Now()
if base.Before(now) {
base = now
}
estimated := base.AddDate(0, 0, int(duration))
if estimated.After(responseExpire) {
responseExpire = estimated
}
}
l.Infow("绑定完成", logger.Field("userId", u.Id), logger.Field("tier", tier), logger.Field("expiresAt", responseExpire.Unix()))
return &types.AttachAppleTransactionResponse{
ExpiresAt: exp.Unix(),
ExpiresAt: responseExpire.Unix(),
Tier: tier,
}, nil
}

View File

@ -74,7 +74,7 @@ func (h *accountMergeHelper) mergeIntoOwner(ownerUserID, deviceUserID int64, sou
return err
}
removedSubscribes, err := clearMemberSubscribes(tx, deviceUserID)
removedSubscribes, err := transferMemberSubscribesToOwner(tx, deviceUserID, ownerUserID)
if err != nil {
return err
}

View File

@ -87,24 +87,54 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query email bind status failed")
}
authInfo := &user.AuthMethods{
UserId: u.Id,
AuthType: "email",
AuthIdentifier: req.Email,
Verified: true,
// Create a new email user and establish family relationship
var emailUser *user.User
if txErr := l.svcCtx.DB.Transaction(func(tx *gorm.DB) error {
emailUser = &user.User{
Salt: "default",
OnlyFirstPurchase: &l.svcCtx.Config.Invite.OnlyFirstPurchase,
}
if err := tx.Create(emailUser).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create email user failed: %v", err)
}
emailUser.ReferCode = uuidx.UserInviteCode(emailUser.Id)
if err := tx.Model(&user.User{}).Where("id = ?", emailUser.Id).Update("refer_code", emailUser.ReferCode).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update refer code failed: %v", err)
}
authInfo := &user.AuthMethods{
UserId: emailUser.Id,
AuthType: "email",
AuthIdentifier: req.Email,
Verified: true,
}
if err := tx.Create(authInfo).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "create email auth method failed: %v", err)
}
return nil
}); txErr != nil {
return nil, txErr
}
if err = l.svcCtx.UserModel.InsertUserAuthMethods(l.ctx, authInfo); err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "bind email failed: %v", err)
// Join family: email user as owner, device user as member
if err = familyHelper.validateJoinFamily(emailUser.Id, u.Id); err != nil {
return nil, err
}
joinResult, err := familyHelper.joinFamily(emailUser.Id, u.Id, "bind_email_with_verification")
if err != nil {
return nil, err
}
token, err := l.refreshBindSessionToken(u.Id)
if err != nil {
return nil, err
}
return &types.BindEmailWithVerificationResponse{
Success: true,
Message: "email bound successfully",
Token: token,
UserId: u.Id,
Success: true,
Message: "email user created and joined family",
Token: token,
UserId: u.Id,
FamilyJoined: true,
FamilyId: joinResult.FamilyId,
OwnerUserId: joinResult.OwnerUserId,
}, nil
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"strconv"
"strings"
"time"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/model/user"
@ -44,30 +45,66 @@ func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountRespon
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
// 事务前先查出 AuthMethods用于事务后精确清缓存
authMethods, _ := l.svcCtx.UserModel.FindUserAuthMethods(l.ctx, currentUser.Id)
// 事务前:查找家庭关系,收集所有成员 ID
allMemberIDs := []int64{currentUser.Id}
var familyId int64
if relation, relErr := l.findActiveFamilyRelation(currentUser.Id); relErr == nil {
familyId = relation.FamilyId
var memberIDs []int64
if pluckErr := l.svcCtx.DB.WithContext(l.ctx).
Model(&user.UserFamilyMember{}).
Where("family_id = ? AND status = ? AND deleted_at IS NULL", familyId, user.FamilyMemberActive).
Pluck("user_id", &memberIDs).Error; pluckErr == nil {
idSet := map[int64]struct{}{currentUser.Id: {}}
for _, id := range memberIDs {
if id > 0 {
if _, exists := idSet[id]; !exists {
idSet[id] = struct{}{}
allMemberIDs = append(allMemberIDs, id)
}
}
}
}
}
affectedUserIDs := []int64{currentUser.Id}
// 事务前:收集所有成员的 AuthMethods事务后用于精确清缓存
allAuthMethods := make([]*user.AuthMethods, 0)
for _, memberID := range allMemberIDs {
if ams, amErr := l.svcCtx.UserModel.FindUserAuthMethods(l.ctx, memberID); amErr == nil {
allAuthMethods = append(allAuthMethods, ams...)
}
}
// 事务内:解散家庭 + 删除所有成员的 AuthMethods + Subscribe
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
familyUserIDs, collectErr := l.collectAffectedFamilyUserIDs(tx, currentUser.Id)
if collectErr != nil {
return collectErr
}
affectedUserIDs = familyUserIDs
exitHelper := newFamilyExitHelper(l.ctx, l.svcCtx)
if removeErr := exitHelper.removeUserFromActiveFamily(tx, currentUser.Id, true); removeErr != nil {
return removeErr
// 无论 member 还是 owner都解散整个家庭
if familyId > 0 {
now := time.Now()
if txErr := tx.Model(&user.UserFamilyMember{}).
Where("family_id = ? AND status = ?", familyId, user.FamilyMemberActive).
Updates(map[string]interface{}{
"status": user.FamilyMemberRemoved,
"left_at": now,
}).Error; txErr != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "remove all family members failed")
}
if txErr := tx.Model(&user.UserFamily{}).
Where("id = ?", familyId).
Updates(map[string]interface{}{
"status": familyStatusDisabled,
}).Error; txErr != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "disable family failed")
}
}
// 解绑所有登录方式(邮箱、手机等)
if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.AuthMethods{}).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "delete user auth methods failed")
}
// 删除该用户的所有订阅
if err := tx.Where("user_id = ?", currentUser.Id).Delete(&user.Subscribe{}).Error; err != nil {
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "delete user subscribes failed")
// 删除所有成员的 AuthMethods 和 Subscribe
for _, memberID := range allMemberIDs {
if txErr := tx.Where("user_id = ?", memberID).Delete(&user.AuthMethods{}).Error; txErr != nil {
l.Errorw("delete auth methods failed", logger.Field("user_id", memberID), logger.Field("error", txErr.Error()))
}
if txErr := tx.Where("user_id = ?", memberID).Delete(&user.Subscribe{}).Error; txErr != nil {
l.Errorw("delete subscribes failed", logger.Field("user_id", memberID), logger.Field("error", txErr.Error()))
}
}
return nil
@ -76,29 +113,23 @@ func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountRespon
return nil, err
}
l.clearAllSessions(currentUser.Id)
// Kick all affected family member devices + clear their sessions
for _, memberUserID := range affectedUserIDs {
if memberUserID == currentUser.Id {
continue
}
// 事务后:清除所有成员的 Session + 踢设备
for _, memberID := range allMemberIDs {
l.clearAllSessions(memberID)
var memberDevices []user.Device
l.svcCtx.DB.WithContext(l.ctx).
Model(&user.Device{}).
Where("user_id = ?", memberUserID).
Where("user_id = ?", memberID).
Find(&memberDevices)
for _, d := range memberDevices {
l.svcCtx.DeviceManager.KickDevice(d.UserId, d.Identifier)
}
l.clearAllSessions(memberUserID)
}
// 主动清 auth method 相关缓存(email/mobile 等 key,避免缓存未命中时无法生成正确 key
if len(authMethods) > 0 {
// auth method 相关缓存email key
if len(allAuthMethods) > 0 {
var authCacheKeys []string
for _, am := range authMethods {
for _, am := range allAuthMethods {
if am.AuthType == "email" && am.AuthIdentifier != "" {
authCacheKeys = append(authCacheKeys, fmt.Sprintf("cache:user:email:%s", am.AuthIdentifier))
}
@ -113,10 +144,11 @@ func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountRespon
}
}
if cacheErr := l.clearUserAndSubscribeCaches(affectedUserIDs); cacheErr != nil {
// 清除所有成员的 user + subscribe 缓存
if cacheErr := l.clearUserAndSubscribeCaches(allMemberIDs); cacheErr != nil {
l.Errorw("clear user related cache failed",
logger.Field("user_id", currentUser.Id),
logger.Field("affected_user_ids", affectedUserIDs),
logger.Field("affected_user_ids", allMemberIDs),
logger.Field("error", cacheErr.Error()),
)
}
@ -334,3 +366,16 @@ func (l *DeleteAccountLogic) clearAllSessions(userId int64) {
logger.Field("count", len(sessionSet)),
)
}
// findActiveFamilyRelation 查找用户的活跃家庭成员记录
func (l *DeleteAccountLogic) findActiveFamilyRelation(userID int64) (*user.UserFamilyMember, error) {
var relation user.UserFamilyMember
err := l.svcCtx.DB.WithContext(l.ctx).
Model(&user.UserFamilyMember{}).
Where("user_id = ? AND status = ?", userID, user.FamilyMemberActive).
First(&relation).Error
if err != nil {
return nil, err
}
return &relation, nil
}

View File

@ -182,7 +182,7 @@ func (h *familyBindingHelper) joinFamily(ownerUserId, memberUserId int64, source
}
}
removedSubscribes, err = clearMemberSubscribes(tx, memberUserId)
removedSubscribes, err = transferMemberSubscribesToOwner(tx, memberUserId, ownerUserId)
if err != nil {
return err
}
@ -199,7 +199,7 @@ func (h *familyBindingHelper) joinFamily(ownerUserId, memberUserId int64, source
return result, nil
}
func clearMemberSubscribes(tx *gorm.DB, memberUserId int64) ([]user.Subscribe, error) {
func transferMemberSubscribesToOwner(tx *gorm.DB, memberUserId, ownerUserId int64) ([]user.Subscribe, error) {
var subscribes []user.Subscribe
if err := tx.Model(&user.Subscribe{}).
Where("user_id = ?", memberUserId).
@ -209,8 +209,10 @@ func clearMemberSubscribes(tx *gorm.DB, memberUserId int64) ([]user.Subscribe, e
if len(subscribes) == 0 {
return nil, nil
}
if err := tx.Where("user_id = ?", memberUserId).Delete(&user.Subscribe{}).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "delete member subscribe list failed")
if err := tx.Model(&user.Subscribe{}).
Where("user_id = ?", memberUserId).
Update("user_id", ownerUserId).Error; err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "transfer member subscribes to owner failed")
}
return subscribes, nil
}

View File

@ -36,6 +36,11 @@ func (l *GetDeviceListLogic) GetDeviceList() (resp *types.GetDeviceListResponse,
list, count, err := l.svcCtx.UserModel.QueryDeviceListByUserIds(l.ctx, scopeUserIds)
userRespList := make([]types.UserDevice, 0)
tool.DeepCopy(&userRespList, list)
for i, d := range list {
if i < len(userRespList) {
userRespList[i].DeviceNo = tool.DeviceIdToHash(d.Id)
}
}
resp = &types.GetDeviceListResponse{
Total: count,
List: userRespList,

View File

@ -9,6 +9,7 @@ import (
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/kutt"
"github.com/perfect-panel/server/pkg/uuidx"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
@ -44,6 +45,21 @@ func (l *QueryUserInfoLogic) QueryUserInfo() (resp *types.User, err error) {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
}
tool.DeepCopy(resp, u)
for i, d := range u.UserDevices {
if i < len(resp.UserDevices) {
resp.UserDevices[i].DeviceNo = tool.DeviceIdToHash(d.Id)
}
}
// refer_code 为空时自动生成
if resp.ReferCode == "" {
resp.ReferCode = uuidx.UserInviteCode(u.Id)
if err := l.svcCtx.DB.Model(&user.User{}).Where("id = ?", u.Id).Update("refer_code", resp.ReferCode).Error; err != nil {
l.Errorw("auto generate refer_code failed", logger.Field("user_id", u.Id), logger.Field("error", err.Error()))
} else {
_ = l.svcCtx.UserModel.ClearUserCache(l.ctx, u)
}
}
ownerEmailMethod := l.fillFamilyContext(resp, u.Id)
var userMethods []types.UserAuthMethod

View File

@ -3,6 +3,8 @@ package user
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/perfect-panel/server/internal/config"
@ -64,10 +66,10 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
}
currentSessionID, _ := l.ctx.Value(constant.CtxKeySessionID).(string)
return l.logoutUnbind(targetUser, device, currentSessionID)
return l.logoutUnbind(userInfo.Id, targetUser, device, currentSessionID)
}
func (l *UnbindDeviceLogic) logoutUnbind(userInfo *user.User, device *user.Device, currentSessionID string) error {
func (l *UnbindDeviceLogic) logoutUnbind(kickerUserID int64, userInfo *user.User, device *user.Device, currentSessionID string) error {
// 1. 事务前查出 AuthMethods用于事务后清邮箱缓存
authMethods, _ := l.svcCtx.UserModel.FindUserAuthMethods(l.ctx, userInfo.Id)
@ -202,13 +204,13 @@ func (l *UnbindDeviceLogic) logoutUnbind(userInfo *user.User, device *user.Devic
}
}
// 6. Kick 设备(缓存已清,重连时 FindOne 会查到最新数据
// 6. Kick 设备(关闭 WebSocket客户端会收到 kicked 消息
l.svcCtx.DeviceManager.KickDevice(device.UserId, device.Identifier)
// 7. 清除该用户所有 session旧 token 全部失效)
l.clearAllSessions(userInfo.Id)
// 8. 清理受影响的家庭成员缓存(家庭解散/转移后成员需感知变化
// 8. 清理受影响的家庭成员缓存 + 踢设备 + 清 session跳过踢人者自己
for _, memberID := range familyMemberIDs {
if memberUser, findErr := l.svcCtx.UserModel.FindOne(l.ctx, memberID); findErr == nil {
if clearErr := l.svcCtx.UserModel.ClearUserCache(l.ctx, memberUser); clearErr != nil {
@ -218,6 +220,24 @@ func (l *UnbindDeviceLogic) logoutUnbind(userInfo *user.User, device *user.Devic
)
}
}
// 踢人者只需刷新缓存,不踢设备、不清 session
if memberID == kickerUserID {
continue
}
// 踢该成员的所有在线设备
var memberDevices []user.Device
l.svcCtx.DB.WithContext(l.ctx).
Model(&user.Device{}).
Where("user_id = ?", memberID).
Find(&memberDevices)
for _, d := range memberDevices {
l.svcCtx.DeviceManager.KickDevice(d.UserId, d.Identifier)
}
// 清除该成员所有 session确保旧 token 失效
l.clearAllSessions(memberID)
}
return nil
@ -246,32 +266,80 @@ func (l *UnbindDeviceLogic) collectFamilyMemberIDs(userID int64) []int64 {
return memberIDs
}
// clearAllSessions 清除指定用户的所有会话
// clearAllSessions 清除指定用户的所有会话(通过 SCAN 查找,不依赖 sorted set
func (l *UnbindDeviceLogic) clearAllSessions(userId int64) {
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
sessions, err := l.svcCtx.Redis.ZRange(l.ctx, sessionsKey, 0, -1).Result()
if err != nil {
l.Errorw("获取用户会话列表失败",
logger.Field("user_id", userId),
logger.Field("error", err.Error()),
)
return
}
sessionSet := make(map[string]struct{})
if len(sessions) == 0 {
return
}
pipe := l.svcCtx.Redis.TxPipeline()
for _, sessionID := range sessions {
if sessionID == "" {
continue
// SCAN 所有 session key找出属于该用户的
userIDText := strconv.FormatInt(userId, 10)
pattern := fmt.Sprintf("%s:*", config.SessionIdKey)
var cursor uint64
for {
keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, pattern, 200).Result()
if scanErr != nil {
l.Errorw("扫描会话键失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error()))
break
}
for _, sessionKey := range keys {
value, getErr := l.svcCtx.Redis.Get(l.ctx, sessionKey).Result()
if getErr != nil || value != userIDText {
continue
}
sessionID := strings.TrimPrefix(sessionKey, config.SessionIdKey+":")
if sessionID == "" || strings.HasPrefix(sessionID, "detail:") {
continue
}
sessionSet[sessionID] = struct{}{}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
// SCAN 设备缓存 key找出关联的
deviceKeySet := make(map[string]struct{})
devicePattern := fmt.Sprintf("%s:*", config.DeviceCacheKeyKey)
cursor = 0
for {
keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, devicePattern, 200).Result()
if scanErr != nil {
l.Errorw("扫描设备会话映射失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error()))
break
}
for _, deviceKey := range keys {
sessionID, getErr := l.svcCtx.Redis.Get(l.ctx, deviceKey).Result()
if getErr != nil {
continue
}
if _, exists := sessionSet[sessionID]; exists {
deviceKeySet[deviceKey] = struct{}{}
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
if len(sessionSet) == 0 {
return
}
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
pipe := l.svcCtx.Redis.TxPipeline()
for sessionID := range sessionSet {
pipe.Del(l.ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID))
pipe.Del(l.ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionID))
pipe.ZRem(l.ctx, sessionsKey, sessionID)
}
pipe.Del(l.ctx, sessionsKey)
if _, err = pipe.Exec(l.ctx); err != nil {
for deviceKey := range deviceKeySet {
pipe.Del(l.ctx, deviceKey)
}
if _, err := pipe.Exec(l.ctx); err != nil {
l.Errorw("清理会话缓存失败",
logger.Field("user_id", userId),
logger.Field("error", err.Error()),
@ -280,7 +348,7 @@ func (l *UnbindDeviceLogic) clearAllSessions(userId int64) {
l.Infow("退出登录-清除所有Session",
logger.Field("user_id", userId),
logger.Field("count", len(sessions)),
logger.Field("count", len(sessionSet)),
)
}

View File

@ -113,17 +113,18 @@ func (l *TelegramAuthConfig) Unmarshal(data string) error {
}
type EmailAuthConfig struct {
Platform string `json:"platform"`
PlatformConfig interface{} `json:"platform_config"`
EnableVerify bool `json:"enable_verify"`
EnableNotify bool `json:"enable_notify"`
EnableDomainSuffix bool `json:"enable_domain_suffix"`
DomainSuffixList string `json:"domain_suffix_list"`
VerifyEmailTemplate string `json:"verify_email_template"`
VerifyEmailTemplates map[string]string `json:"verify_email_templates"`
ExpirationEmailTemplate string `json:"expiration_email_template"`
MaintenanceEmailTemplate string `json:"maintenance_email_template"`
TrafficExceedEmailTemplate string `json:"traffic_exceed_email_template"`
Platform string `json:"platform"`
PlatformConfig interface{} `json:"platform_config"`
EnableVerify bool `json:"enable_verify"`
EnableNotify bool `json:"enable_notify"`
EnableDomainSuffix bool `json:"enable_domain_suffix"`
DomainSuffixList string `json:"domain_suffix_list"`
VerifyEmailTemplate string `json:"verify_email_template"`
VerifyEmailTemplates map[string]string `json:"verify_email_templates"`
ExpirationEmailTemplate string `json:"expiration_email_template"`
MaintenanceEmailTemplate string `json:"maintenance_email_template"`
TrafficExceedEmailTemplate string `json:"traffic_exceed_email_template"`
DeleteAccountEmailTemplate string `json:"delete_account_email_template"`
}
func (l *EmailAuthConfig) Marshal() string {
@ -136,6 +137,9 @@ func (l *EmailAuthConfig) Marshal() string {
if l.TrafficExceedEmailTemplate == "" {
l.TrafficExceedEmailTemplate = email.DefaultTrafficExceedEmailTemplate
}
if l.DeleteAccountEmailTemplate == "" {
l.DeleteAccountEmailTemplate = email.DefaultDeleteAccountEmailTemplate
}
if l.VerifyEmailTemplate == "" {
l.VerifyEmailTemplate = email.DefaultEmailVerifyTemplate
}
@ -145,17 +149,18 @@ func (l *EmailAuthConfig) Marshal() string {
bytes, err := json.Marshal(l)
if err != nil {
config := &EmailAuthConfig{
Platform: "smtp",
PlatformConfig: new(SMTPConfig),
EnableVerify: true,
EnableNotify: true,
EnableDomainSuffix: false,
DomainSuffixList: "",
VerifyEmailTemplate: email.DefaultEmailVerifyTemplate,
VerifyEmailTemplates: map[string]string{},
ExpirationEmailTemplate: email.DefaultExpirationEmailTemplate,
MaintenanceEmailTemplate: email.DefaultMaintenanceEmailTemplate,
TrafficExceedEmailTemplate: email.DefaultTrafficExceedEmailTemplate,
Platform: "smtp",
PlatformConfig: new(SMTPConfig),
EnableVerify: true,
EnableNotify: true,
EnableDomainSuffix: false,
DomainSuffixList: "",
VerifyEmailTemplate: email.DefaultEmailVerifyTemplate,
VerifyEmailTemplates: map[string]string{},
ExpirationEmailTemplate: email.DefaultExpirationEmailTemplate,
MaintenanceEmailTemplate: email.DefaultMaintenanceEmailTemplate,
TrafficExceedEmailTemplate: email.DefaultTrafficExceedEmailTemplate,
DeleteAccountEmailTemplate: email.DefaultDeleteAccountEmailTemplate,
}
bytes, _ = json.Marshal(config)
@ -189,6 +194,9 @@ func (l *EmailAuthConfig) Unmarshal(data string) {
if l.VerifyEmailTemplates == nil {
l.VerifyEmailTemplates = map[string]string{}
}
if l.DeleteAccountEmailTemplate == "" {
l.DeleteAccountEmailTemplate = email.DefaultDeleteAccountEmailTemplate
}
}
// SMTPConfig Email SMTP configuration

View File

@ -13,7 +13,7 @@ const (
FamilyMemberActive uint8 = 1
FamilyMemberLeft uint8 = 2
FamilyMemberRemoved uint8 = 3
DefaultFamilyMaxSize int64 = 2
DefaultFamilyMaxSize int64 = 3
)
type UserFamily struct {

View File

@ -8,6 +8,7 @@ import (
"github.com/perfect-panel/server/internal/model/order"
"github.com/perfect-panel/server/internal/model/subscribe"
"github.com/perfect-panel/server/pkg/tool"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
@ -173,8 +174,16 @@ func (m *customUserModel) QueryPageList(ctx context.Context, page, size int, fil
conn = conn.Where("user.id =?", *filter.UserId)
}
if filter.Search != "" {
conn = conn.Joins("LEFT JOIN user_auth_methods ON user.id = user_auth_methods.user_id").
Where("user_auth_methods.auth_identifier LIKE ?", "%"+filter.Search+"%").Or("user.refer_code like ?", "%"+filter.Search+"%")
// 尝试将搜索词解码为 device_no8位hex → device id
if deviceId, err := tool.HashToDeviceId(filter.Search); err == nil && deviceId > 0 {
conn = conn.Joins("LEFT JOIN user_auth_methods ON user.id = user_auth_methods.user_id").
Joins("LEFT JOIN user_device ON user.id = user_device.user_id").
Where("user_auth_methods.auth_identifier LIKE ? OR user.refer_code LIKE ? OR user_device.id = ?",
"%"+filter.Search+"%", "%"+filter.Search+"%", deviceId)
} else {
conn = conn.Joins("LEFT JOIN user_auth_methods ON user.id = user_auth_methods.user_id").
Where("user_auth_methods.auth_identifier LIKE ?", "%"+filter.Search+"%").Or("user.refer_code like ?", "%"+filter.Search+"%")
}
}
if filter.UserSubscribeId != nil {
conn = conn.Joins("LEFT JOIN user_subscribe ON user.id = user_subscribe.user_id").

View File

@ -682,6 +682,7 @@ type FamilyDetail struct {
type FamilyMemberItem struct {
UserId int64 `json:"user_id"`
Identifier string `json:"identifier"`
DeviceNo string `json:"device_no"`
Role uint8 `json:"role"`
RoleName string `json:"role_name"`
Status uint8 `json:"status"`
@ -2956,6 +2957,7 @@ type UserDevice struct {
Id int64 `json:"id"`
Ip string `json:"ip"`
Identifier string `json:"identifier"`
DeviceNo string `json:"device_no"`
UserAgent string `json:"user_agent"`
Online bool `json:"online"`
Enabled bool `json:"enabled"`

View File

@ -27,12 +27,12 @@ const (
<tr>
<td style="padding:0 43px 0 43px;">
<h1 style="font-weight:600; font-size:36px; line-height:1.4; color:#0F2C53; margin:0 0 24px 0;">
注销验证
验证
</h1>
<p style="font-weight:400; font-size:16px; line-height:1.4; color:#0F2C53; margin:0 0 24px 0; white-space:pre-line;">
亲爱的用户
我们收到了你在{{.SiteName}}的注销请求
进行{{.SiteName}}账户相关操作
请在系统提示时输入以下验证码
</p>
@ -299,5 +299,85 @@ const (
<div class="footer">此为系统邮件请勿回复</div>
</div>
</body>
</html>`
DefaultDeleteAccountEmailTemplate = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin:0; padding:0; background-color:#f4f4f4; font-family:'PingFang SC','Microsoft YaHei',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f4f4f4">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" border="0" bgcolor="#ffffff" style="min-height:740px; padding-bottom:53px;">
<!-- Top Logo -->
<tr>
<td align="right" style="padding:32px 40px 0 0;">
{{if .SiteLogo}}<img height="47" style="display:block;" src="{{.SiteLogo}}" alt="{{.SiteName}}">{{end}}
</td>
</tr>
<!-- Spacer -->
<tr><td height="70"></td></tr>
<!-- Main Content -->
<tr>
<td style="padding:0 43px 0 43px;">
<h1 style="font-weight:600; font-size:36px; line-height:1.4; color:#0F2C53; margin:0 0 24px 0;">
账户注销确认
</h1>
<p style="font-weight:400; font-size:16px; line-height:1.4; color:#0F2C53; margin:0 0 24px 0; white-space:pre-line;">
亲爱的用户
我们收到了你注销 {{.SiteName}} 账户的请求
请在系统提示时输入以下验证码以完成注销
</p>
<div style="font-weight:600; font-size:48px; line-height:1.4; color:#0F2C53; margin:24px 0; letter-spacing:2px;">
{{.Code}}
</div>
<p style="font-weight:400; font-size:16px; line-height:1.4; color:#0F2C53; margin:24px 0 0 0;">
该验证码将在 {{.Expire}} 分钟 后过期
</p>
<!-- Warning Box -->
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;">
<tr>
<td style="background:#FFF3CD; border-left:4px solid #FF8C00; padding:16px 20px; border-radius:4px;">
<p style="margin:0; font-size:15px; color:#5C3D00; line-height:1.6;">
<strong>重要提示</strong>账户注销后你的所有数据包括订阅套餐账户信息将被<strong>永久删除且不可恢复</strong>请确认你已充分了解此操作的后果
</p>
</td>
</tr>
</table>
<p style="font-weight:400; font-size:16px; line-height:1.4; color:#0F2C53; margin:24px 0 0 0; white-space:pre-line;">
如果这不是你本人操作请忽略本邮件你的账户将保持安全
谢谢
{{.SiteName}}团队
</p>
</td>
</tr>
<!-- Spacer -->
<tr><td height="50"></td></tr>
<!-- Footer Logo -->
<tr>
<td align="center" style="padding-bottom:20px;">
{{if .SiteLogo}}<img width="52" height="52" style="display:block; margin:0 auto;" src="{{.SiteLogo}}" alt="{{.SiteName}}">{{end}}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
)

25
pkg/tool/device.go Normal file
View File

@ -0,0 +1,25 @@
package tool
import (
"fmt"
"strings"
)
const deviceHashSalt uint32 = 0x5A3C7E9B
// DeviceIdToHash encodes a device id to an 8-char uppercase hex string.
// Algorithm mirrors frontend: id XOR salt → hex.
// e.g. 1 → "5A3C7E9A", 42 → "5A3C7EA1"
func DeviceIdToHash(id int64) string {
return strings.ToUpper(fmt.Sprintf("%08x", uint32(id)^deviceHashSalt))
}
// HashToDeviceId decodes an 8-char hex hash back to a device id.
func HashToDeviceId(hash string) (int64, error) {
var n uint32
_, err := fmt.Sscanf(strings.ToLower(hash), "%08x", &n)
if err != nil {
return 0, err
}
return int64(n ^ deviceHashSalt), nil
}

View File

@ -60,14 +60,6 @@ func (l *SendEmailLogic) ProcessTask(ctx context.Context, task *asynq.Task) erro
scene := resolveVerifyScene(payload.Scene, typeVal)
tplStr := selectVerifyTemplate(l.svcCtx.Config.Email.VerifyEmailTemplates, l.svcCtx.Config.Email.VerifyEmailTemplate, scene)
if tplStr == l.svcCtx.Config.Email.VerifyEmailTemplate &&
scene == constant.DeleteAccount.String() &&
!strings.Contains(tplStr, "Type 4") &&
!strings.Contains(tplStr, "Type eq 4") {
logger.WithContext(ctx).Infow("[SendEmailLogic] configured legacy verify template may not support DeleteAccount, fallback to default template")
tplStr = email.DefaultEmailVerifyTemplate
}
tpl, _ := template.New("verify").Parse(tplStr)
var result bytes.Buffer
@ -80,6 +72,23 @@ func (l *SendEmailLogic) ProcessTask(ctx context.Context, task *asynq.Task) erro
return nil
}
content = result.String()
case types.EmailTypeDeleteAccount:
tplStr := l.svcCtx.Config.Email.DeleteAccountEmailTemplate
if tplStr == "" {
tplStr = email.DefaultDeleteAccountEmailTemplate
}
tpl, _ := template.New("delete_account").Parse(tplStr)
var result bytes.Buffer
err = tpl.Execute(&result, payload.Content)
if err != nil {
logger.WithContext(ctx).Error("[SendEmailLogic] Execute template failed",
logger.Field("error", err.Error()),
logger.Field("template", l.svcCtx.Config.Email.DeleteAccountEmailTemplate),
logger.Field("data", payload.Content),
)
return nil
}
content = result.String()
case types.EmailTypeMaintenance:
tplStr := l.svcCtx.Config.Email.MaintenanceEmailTemplate
if tplStr == "" {

View File

@ -10,6 +10,7 @@ const (
EmailTypeMaintenance = "maintenance"
EmailTypeExpiration = "expiration"
EmailTypeTrafficExceed = "traffic_exceed"
EmailTypeDeleteAccount = "delete_account"
EmailTypeCustom = "custom"
)

319
scripts/MIGRATION_REPORT.md Normal file
View File

@ -0,0 +1,319 @@
# 付费用户数据迁移报告
> 生成时间: 2026-03-13
> 脚本: `scripts/export_paid_users.sh`
> 输出: `scripts/output/paid_users_migration.sql` (3189 行)
---
## 1. 数据总览
### 源库统计
| 指标 | 全量 | 付费用户筛选后 | 丢弃 |
|------|-----:|---------------:|-----:|
| 用户 (user) | 1,864 | **482** | 1,382 (74%) |
| 登录方式 (user_auth_methods) | 2,041 | **598** | 1,443 |
| 设备 (user_device) | 1,415 | **496** | 919 |
| 订单 (order) | — | **1,669** | — |
| 已完成订单 (status=3) | 1,806 | — | — |
| 订阅 (user_subscribe) | 1,588 | **526** | 1,062 |
| IAP 交易 (apple_iap_transactions) | 168 | **29** | 139 |
| 系统日志 (system_logs) | 20,264 | **4,830** | 15,434 |
| 套餐定义 (subscribe) | 1 | **1** (全量) | — |
| 支付方式 (payment) | 4 | **4** (全量) | — |
| 系统配置 (system) | 53 | **53** (全量) | — |
### 新增数据(迁移脚本自动生成)
| 指标 | 数量 | 说明 |
|------|-----:|------|
| 家庭组 (user_family) | **482** | 每个付费用户 1 个 |
| 家庭成员 (user_family_members) | **482 + 24** | 482 家主 + 24 拆分设备 |
| 新用户 (拆分设备) | **24** | 多设备用户第 2 个设备→独立用户 |
### 幽灵用户
| 类型 | 数量 | 处理 |
|------|-----:|------|
| 有订单/IAP 但 user 表不存在 | **82** | **已排除**INNER JOIN user |
| user_id=0 的脏数据 | 若干 | **已排除**WHERE user_id > 0 |
---
## 2. 付费用户定义
```sql
SELECT DISTINCT t.uid FROM (
SELECT user_id AS uid FROM `order` WHERE status=3 AND user_id > 0
UNION
SELECT user_id AS uid FROM apple_iap_transactions WHERE user_id > 0
) t
INNER JOIN user u ON u.id = t.uid
ORDER BY t.uid;
```
**逻辑分析:**
1. **子查询 1**`order WHERE status=3`查找所有已完成支付的订单status=3 = 支付完成),提取 `user_id`
2. **子查询 2**`apple_iap_transactions`:查找所有 Apple IAP 交易记录的 `user_id`
3. **UNION**:合并去重,满足**任一条件**即为付费用户
4. **WHERE user_id > 0**:排除 `user_id=0` 的脏数据
5. **INNER JOIN user**:只保留在 `user` 表中**实际存在**的用户(排除 82 个幽灵用户)
**结果**564 个候选 → 排除 82 幽灵 → **482 个有效付费用户**
---
## 3. 脚本分步 SQL 逻辑分析
### Step 1: 查询付费用户 ID
见上方"付费用户定义"。输出为换行分隔的 ID 列表,转为逗号分隔用于后续 WHERE IN。
### Step 2: SQL 文件头
```sql
SET NAMES utf8mb4; -- 确保中文字符正确
SET FOREIGN_KEY_CHECKS = 0; -- 禁用外键检查,允许无序插入
SET UNIQUE_CHECKS = 0; -- 禁用唯一键检查,加速批量插入
SET AUTOCOMMIT = 0; -- 开启事务模式
CREATE DATABASE IF NOT EXISTS `ppanel` ...;
USE `ppanel`;
```
**目的**:创建安全的导入环境,避免外键/唯一键冲突导致中断。
### Step 3: 导出表结构DDL
```bash
mysqldump --no-data --skip-add-drop-table \
user user_auth_methods user_device \
order user_subscribe apple_iap_transactions \
subscribe payment system system_logs
```
**逻辑**
- `--no-data`:只导出 CREATE TABLE 语句,不含数据
- `--skip-add-drop-table`:不生成 `DROP TABLE IF EXISTS`,避免误删新库已有表
- 后处理 `sed``CREATE TABLE` 改为 `CREATE TABLE IF NOT EXISTS`
- **手动追加** `user_family``user_family_members` DDL新系统表源库可能没有
**涉及 12 张表**
| 表 | 类型 |
|----|------|
| user | 用户主表 |
| user_auth_methods | 登录方式email/device/telephone |
| user_device | 设备记录 |
| order | 订单 |
| user_subscribe | 用户订阅 |
| apple_iap_transactions | Apple IAP 交易 |
| subscribe | 套餐定义(全量配置表) |
| payment | 支付方式(全量配置表) |
| system | 系统配置(全量配置表) |
| system_logs | 系统日志 |
| user_family | 家庭组(新表,手动 DDL |
| user_family_members | 家庭成员(新表,手动 DDL |
### Step 4: 全量配置表数据
```bash
for TBL in subscribe payment system; do
mysqldump --no-create-info --complete-insert --skip-extended-insert "${TBL}"
done
```
**逻辑**
- `--no-create-info`:只导出 INSERT不重复 DDL
- `--complete-insert`:生成包含列名的完整 INSERT兼容性更好
- `--skip-extended-insert`:每行一条 INSERT便于阅读和调试
- 这三张表**不按用户过滤**,全量导出
**数据量**subscribe 1 条 + payment 4 条 + system 53 条 = 58 条
### Step 5: 付费用户关联数据
```bash
export_table_by_user_ids() {
mysqldump --no-create-info --complete-insert \
--where="${COL} IN (${PAID_ID_LIST})" "${TBL}"
}
```
逐表使用 `--where` 子句过滤:
| 表 | 过滤列 | 导出数量 | SQL 逻辑 |
|----|--------|---------|---------|
| `user` | `id` | 482 | `WHERE id IN (1,5,7,...)` — 只导出付费用户的用户记录 |
| `user_auth_methods` | `user_id` | 598 | `WHERE user_id IN (...)` — 付费用户的所有登录方式 |
| `user_device` | `user_id` | 496 | `WHERE user_id IN (...)` — 付费用户的所有设备 |
| `order` | `user_id` | 1,669 | `WHERE user_id IN (...)` — 付费用户的**所有**订单(含未完成) |
| `user_subscribe` | `user_id` | 526 | `WHERE user_id IN (...)` — 付费用户的订阅记录 |
| `apple_iap_transactions` | `user_id` | 29 | `WHERE user_id IN (...)` — 付费用户的 IAP 交易 |
**注意**`order` 表导出的是付费用户的**全部订单**1,669 条),不仅仅是 status=3 的。这是合理的——保留用户完整的订单历史。
### Step 6: 系统日志
```sql
mysqldump --where="object_id IN (${PAID_ID_LIST})" system_logs
```
**逻辑**`system_logs.object_id` 记录的是操作对象 ID通常是 user_id。按付费用户 ID 过滤。
**注意**`object_id` 不一定都是 user_id不同 type 含义不同),可能多导或少导少量记录,影响不大。
**数据量**4,830 条
### Step 7: 家庭组初始化
```sql
-- 对每个付费用户执行:
INSERT INTO user_family (owner_user_id, max_members, status, created_at, updated_at)
VALUES ({user_id}, 2, 1, NOW(), NOW());
INSERT INTO user_family_members (family_id, user_id, role, status, join_source, joined_at, ...)
VALUES (LAST_INSERT_ID(), {user_id}, 1, 1, 'migration', NOW(), NOW(), NOW());
```
**逻辑分析**
1. 遍历 482 个付费用户 ID
2. 为每个用户创建 **1 个家庭组**`user_family`
- `owner_user_id` = 该用户 ID
- `max_members = 2`(默认最多 2 人)
- `status = 1`(活跃)
3. 将该用户添加为**家主**`user_family_members`
- `family_id = LAST_INSERT_ID()` — 引用刚插入的家庭组 ID
- `role = 1`(家主)
- `status = 1`(活跃)
- `join_source = 'migration'`(标记来源为迁移)
**LAST_INSERT_ID() 链式调用**MySQL 保证 `LAST_INSERT_ID()` 返回同一连接中最后一次 AUTO_INCREMENT 的值,在顺序执行的 SQL 中是安全的。
### Step 8: 多设备用户拆分
**背景**:旧系统中同一 user_id 可以有多个设备。新系统要求每个设备 = 独立用户,通过家庭组关联。
**查询多设备用户的第二个设备**
```sql
SELECT ud.user_id, ud.id, ud.Identifier, ud.user_agent, ud.created_at, ua.id
FROM user_device ud
INNER JOIN user_auth_methods ua
ON ua.user_id = ud.user_id
AND ua.auth_type = 'device'
AND ua.auth_identifier = ud.Identifier
WHERE ud.user_id IN (
-- 找到有 >1 个设备的付费用户
SELECT user_id FROM user_device
WHERE user_id IN ({paid_ids})
GROUP BY user_id HAVING COUNT(*) > 1
)
AND ud.id NOT IN (
-- 排除每个用户的第一个设备MIN(id) = 最早注册的设备)
SELECT MIN(id) FROM user_device
WHERE user_id IN (...多设备用户...)
GROUP BY user_id
)
```
**逻辑分析**
1. **识别多设备用户**`GROUP BY user_id HAVING COUNT(*) > 1` → 找到 24 个用户
2. **保留第一个设备**`MIN(id)` = 最早注册的设备,保留在原 user 上
3. **INNER JOIN user_auth_methods**:通过 `auth_type='device'` + `auth_identifier=Identifier` 关联设备的登录方式记录
4. **输出**:每个需要拆分的设备的完整信息
**对每个需要拆分的设备生成 SQL**
```sql
-- 1. 创建新用户(无密码无邮箱的纯设备用户)
INSERT INTO user (password, algo, salt, enable, is_admin, created_at, updated_at)
VALUES ('', 'default', 'default', 1, 0, '{device_created}', NOW());
SET @new_user_id = LAST_INSERT_ID();
-- 2. 将设备记录转移到新用户
UPDATE user_device SET user_id = @new_user_id WHERE id = {device_id};
-- 3. 将设备的 auth_method 转移到新用户
UPDATE user_auth_methods SET user_id = @new_user_id WHERE id = {auth_method_id};
-- 4. 将新用户加入原用户的家庭组
INSERT INTO user_family_members (family_id, user_id, role, status, join_source, ...)
VALUES (
(SELECT id FROM user_family WHERE owner_user_id = {owner_uid}),
@new_user_id, 2, 1, 'migration_split', ...
);
```
**处理流程**
```
原 user(id=100, 2 个设备)
├─ device_1 (id=50, MIN) → 保留在 user 100 上(已是家主)
└─ device_2 (id=51) → 创建新 user(id=NEW)
→ UPDATE user_device SET user_id=NEW WHERE id=51
→ UPDATE user_auth_methods SET user_id=NEW WHERE id=...
→ INSERT user_family_members(family_id=..., user_id=NEW, role=2)
```
**结果**24 个设备被拆分为独立用户,并加入原用户的家庭组作为 member。
### 文件尾
```sql
SET FOREIGN_KEY_CHECKS = 1; -- 恢复外键检查
SET UNIQUE_CHECKS = 1; -- 恢复唯一键检查
COMMIT; -- 提交事务
```
---
## 4. 数据完整性校验点
| 校验项 | 预期值 | 说明 |
|--------|--------|------|
| 导入后 user 数 | 482 + 24 = **506** | 482 原始 + 24 拆分 |
| user_family 数 | **482** | 每个付费用户 1 个家庭组 |
| user_family_members 数 | **506** | 482 家主 + 24 成员 |
| 每个 family 的成员数 | 1 或 2 | 无拆分=1有拆分=2 |
| role=1 的成员数 | **482** | 每个家庭只有 1 个家主 |
| role=2 的成员数 | **24** | 拆分设备的新用户 |
| user_device.user_id 无孤儿 | 全部指向存在的 user | 拆分后 device 指向新 user |
| user_auth_methods.user_id 无孤儿 | 全部指向存在的 user | 拆分后 auth 指向新 user |
---
## 5. 风险与缓解
| 风险 | 级别 | 缓解措施 |
|------|------|----------|
| 新库已有数据ID 冲突 | 高 | 新库应为空库;或改用 `INSERT IGNORE` |
| `refer_code` 唯一键冲突 | 中 | 迁移用户保留原值,新库确保无重复 |
| `LAST_INSERT_ID()` 链断裂 | 低 | SQL 文件必须**顺序执行**,不可并行 |
| 设备拆分后原用户订阅归属 | 低 | 订阅保留在原 user 上,新 user 通过家庭组共享 |
| `system_logs.object_id` 语义不一致 | 低 | 不同 type 的 object_id 含义不同,可能多导 |
---
## 6. 导入命令
```bash
docker exec -i <新容器> mysql -uroot -p<密码> < scripts/output/paid_users_migration.sql
```
---
## 7. 不导出的表(已丢弃)
| 表 | 原因 |
|----|------|
| traffic_log | 体积大,非必要 |
| ads / announcement / coupon / document | 0 条或非用户数据 |
| nodes / servers / server / server_group | 节点配置,不随用户迁移 |
| ticket / ticket_follow | 工单数据 |
| task | 0 条 |
| schema_migrations | 迁移记录 |
| log_message / application_versions | 0 条 |
| subscribe_application | 应用配置 |
| user_device_online_record | 0 条 |

View File

@ -0,0 +1,109 @@
-- ============================================================
-- 修复脚本:为有多设备但无家庭组的用户补建家庭组
-- 影响用户数30
-- 每个用户2 个设备0 个家庭组
-- 执行前请先备份!
-- ============================================================
-- ============================================================
-- Step 0: 确认受影响数据(只读,不做任何修改)
-- ============================================================
SELECT
d.user_id,
COUNT(*) as device_count,
GROUP_CONCAT(d.id ORDER BY d.id) as device_ids
FROM user_device d
LEFT JOIN user_family_member fm ON fm.user_id = d.user_id AND fm.status = 1
WHERE d.enabled = 1 AND fm.id IS NULL
GROUP BY d.user_id
HAVING device_count > 1
ORDER BY d.user_id;
-- 预期结果30 行
-- ============================================================
-- Step 1: 为每个用户创建 user_family家庭组
-- owner_user_id = user_id, max_members = 2, status = 1(active)
-- ============================================================
INSERT INTO user_family (owner_user_id, max_members, status, created_at, updated_at)
SELECT
d.user_id,
2, -- max_members = 2当前都是 2 设备)
1, -- status = active
MIN(d.created_at), -- 用最早设备的创建时间
NOW()
FROM user_device d
LEFT JOIN user_family_member fm ON fm.user_id = d.user_id AND fm.status = 1
LEFT JOIN user_family f ON f.owner_user_id = d.user_id AND f.deleted_at IS NULL
WHERE d.enabled = 1
AND fm.id IS NULL -- 没有 active 家庭成员记录
AND f.id IS NULL -- 没有已存在的家庭
GROUP BY d.user_id
HAVING COUNT(*) > 1;
-- 预期影响30 行
-- ============================================================
-- Step 2: 为每个用户创建 user_family_memberowner 身份)
-- role = 1(owner), status = 1(active), join_source = 'data_fix'
-- ============================================================
INSERT INTO user_family_member (family_id, user_id, role, status, join_source, joined_at, created_at, updated_at)
SELECT
f.id, -- 刚创建的 family_id
f.owner_user_id, -- user_id
1, -- role = owner
1, -- status = active
'data_fix', -- 标记来源,方便追溯
f.created_at, -- joined_at = family 创建时间
NOW(),
NOW()
FROM user_family f
LEFT JOIN user_family_member fm ON fm.user_id = f.owner_user_id AND fm.status = 1
WHERE fm.id IS NULL -- 还没有 active 家庭成员记录
AND f.deleted_at IS NULL
AND f.owner_user_id IN (
-- 只处理我们目标用户
SELECT d.user_id
FROM user_device d
WHERE d.enabled = 1
GROUP BY d.user_id
HAVING COUNT(*) > 1
);
-- 预期影响30 行
-- ============================================================
-- Step 3: 验证修复结果
-- ============================================================
-- 3a. 确认所有多设备用户都有了家庭组
SELECT
d.user_id,
COUNT(DISTINCT d.id) as device_count,
f.id as family_id,
f.max_members,
fm.role,
fm.status as member_status,
fm.join_source
FROM user_device d
JOIN user_family f ON f.owner_user_id = d.user_id AND f.deleted_at IS NULL
JOIN user_family_member fm ON fm.user_id = d.user_id AND fm.status = 1
WHERE d.enabled = 1
AND fm.join_source = 'data_fix'
GROUP BY d.user_id, f.id, f.max_members, fm.role, fm.status, fm.join_source
ORDER BY d.user_id;
-- 预期结果30 行,每行 device_count=2, role=1, member_status=1
-- 3b. 确认没有遗漏(多设备无家庭组的用户应该为 0
SELECT COUNT(*) as remaining_orphans
FROM (
SELECT d.user_id
FROM user_device d
LEFT JOIN user_family_member fm ON fm.user_id = d.user_id AND fm.status = 1
WHERE d.enabled = 1 AND fm.id IS NULL
GROUP BY d.user_id
HAVING COUNT(*) > 1
) orphans;
-- 预期结果0

View File

@ -0,0 +1,283 @@
-- ============================================================
-- 修复脚本:将同一 user_id 下的多设备拆分为独立用户 + 家庭组
--
-- 问题:代码模型要求 每个设备 = 独立用户,多设备通过家庭组关联
-- 但旧数据中同一 user_id 下挂了多个 user_device
--
-- 修复策略:
-- 1. 每个用户保留第一个设备(最早创建的),作为 family owner
-- 2. 其余设备各创建一个新 user作为 family member
-- 3. 建立家庭组关系
--
-- ⚠️ 执行前请先备份!
-- ============================================================
-- ============================================================
-- Step 0: 诊断 - 查看受影响的用户和设备
-- ============================================================
SELECT
d.user_id,
COUNT(*) as device_count,
GROUP_CONCAT(d.id ORDER BY d.created_at ASC) as device_ids,
GROUP_CONCAT(d.identifier ORDER BY d.created_at ASC SEPARATOR ' | ') as identifiers
FROM user_device d
WHERE d.enabled = 1
GROUP BY d.user_id
HAVING device_count > 1
ORDER BY d.user_id;
-- ============================================================
-- Step 1: 创建临时表,标记需要拆分的设备
-- 每个用户保留最早的设备,其余标记为需要拆分
-- ============================================================
DROP TEMPORARY TABLE IF EXISTS tmp_devices_to_split;
CREATE TEMPORARY TABLE tmp_devices_to_split AS
SELECT
d.id as device_id,
d.user_id as original_user_id,
d.identifier,
d.ip,
d.user_agent,
d.created_at as device_created_at,
ROW_NUMBER() OVER (PARTITION BY d.user_id ORDER BY d.created_at ASC) as rn
FROM user_device d
WHERE d.enabled = 1
AND d.user_id IN (
SELECT user_id
FROM user_device
WHERE enabled = 1
GROUP BY user_id
HAVING COUNT(*) > 1
);
-- 确认rn=1 的保留在原用户rn>1 的需要创建新用户
SELECT * FROM tmp_devices_to_split ORDER BY original_user_id, rn;
-- ============================================================
-- Step 2: 为 rn>1 的设备创建新用户
-- 复制原用户的基本配置,生成新的 refer_code
-- ============================================================
-- 先看需要创建多少个新用户
SELECT COUNT(*) as new_users_needed FROM tmp_devices_to_split WHERE rn > 1;
-- 创建新用户(从原用户复制基本信息)
INSERT INTO `user` (
password, algo, salt, avatar, balance,
refer_code, referer_id, commission,
referral_percentage, only_first_purchase, gift_amount,
enable, is_admin,
enable_balance_notify, enable_login_notify,
enable_subscribe_notify, enable_trade_notify,
rules, member_status, remark,
created_at, updated_at
)
SELECT
u.password, u.algo, u.salt, '', 0,
'', -- refer_code 后面更新
u.referer_id, 0,
u.referral_percentage, u.only_first_purchase, 0,
u.enable, 0, -- is_admin = false
0, 0, 0, 0, -- 通知全关
'', '', CONCAT('split_from_user_', u.id),
NOW(), NOW()
FROM tmp_devices_to_split t
JOIN `user` u ON u.id = t.original_user_id
WHERE t.rn > 1;
-- ============================================================
-- Step 3: 映射新用户 ID 到设备
-- 因为 MySQL 不支持 INSERT ... RETURNING需要通过 remark 字段找到新创建的用户
-- ============================================================
DROP TEMPORARY TABLE IF EXISTS tmp_new_user_mapping;
CREATE TEMPORARY TABLE tmp_new_user_mapping AS
SELECT
u.id as new_user_id,
CAST(SUBSTRING(u.remark, LENGTH('split_from_user_') + 1) AS UNSIGNED) as original_user_id,
u.created_at
FROM `user` u
WHERE u.remark LIKE 'split_from_user_%'
AND u.deleted_at IS NULL
ORDER BY u.id ASC;
-- 验证映射关系
SELECT * FROM tmp_new_user_mapping;
-- 将新用户与待拆分设备匹配(按原用户分组内的顺序)
DROP TEMPORARY TABLE IF EXISTS tmp_device_user_mapping;
CREATE TEMPORARY TABLE tmp_device_user_mapping AS
SELECT
t.device_id,
t.original_user_id,
t.identifier,
m.new_user_id
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY original_user_id ORDER BY device_id ASC) as split_seq
FROM tmp_devices_to_split
WHERE rn > 1
) t
JOIN (
SELECT *, ROW_NUMBER() OVER (PARTITION BY original_user_id ORDER BY new_user_id ASC) as split_seq
FROM tmp_new_user_mapping
) m ON t.original_user_id = m.original_user_id AND t.split_seq = m.split_seq;
-- 确认映射
SELECT * FROM tmp_device_user_mapping;
-- ============================================================
-- Step 4: 更新设备的 user_id 指向新用户
-- ============================================================
UPDATE user_device d
JOIN tmp_device_user_mapping m ON d.id = m.device_id
SET d.user_id = m.new_user_id;
-- ============================================================
-- Step 5: 为新用户创建 device auth_method
-- ============================================================
INSERT INTO user_auth_methods (user_id, auth_type, auth_identifier, verified, created_at, updated_at)
SELECT
m.new_user_id,
'device',
m.identifier,
1,
NOW(),
NOW()
FROM tmp_device_user_mapping m;
-- ============================================================
-- Step 6: 更新新用户的 refer_code
-- 用 CONCAT('u', CONV(new_user_id + UNIX_TIMESTAMP(), 10, 36)) 生成简单唯一码
-- Go 代码用 Base62SQL 里用 Base36 近似,长度足够唯一)
-- ============================================================
UPDATE `user` u
JOIN tmp_new_user_mapping m ON u.id = m.new_user_id
SET u.refer_code = CONCAT('u', LOWER(CONV(u.id + UNIX_TIMESTAMP(NOW()), 10, 36)));
-- 清理 remark 标记
UPDATE `user` u
JOIN tmp_new_user_mapping m ON u.id = m.new_user_id
SET u.remark = '';
-- ============================================================
-- Step 7: 创建/确保家庭组(原用户为 owner
-- 先处理已有家庭组的情况,再处理没有的
-- ============================================================
-- 7a. 为没有 active 家庭组的原用户创建家庭组
INSERT INTO user_family (owner_user_id, max_members, status, created_at, updated_at)
SELECT DISTINCT
t.original_user_id,
2,
1, -- active
NOW(),
NOW()
FROM tmp_device_user_mapping t
LEFT JOIN user_family f ON f.owner_user_id = t.original_user_id
AND f.status = 1 AND f.deleted_at IS NULL
WHERE f.id IS NULL;
-- 7b. 确保原用户在家庭组中有 owner 成员记录
INSERT INTO user_family_member (family_id, user_id, role, status, join_source, joined_at, created_at, updated_at)
SELECT
f.id,
f.owner_user_id,
1, -- role = owner
1, -- status = active
'data_fix_split',
NOW(),
NOW(),
NOW()
FROM user_family f
WHERE f.owner_user_id IN (SELECT DISTINCT original_user_id FROM tmp_device_user_mapping)
AND f.status = 1
AND f.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM user_family_member fm
WHERE fm.family_id = f.id
AND fm.user_id = f.owner_user_id
AND fm.status = 1
AND fm.deleted_at IS NULL
);
-- 7c. 将新用户加入家庭组作为 member
INSERT INTO user_family_member (family_id, user_id, role, status, join_source, joined_at, created_at, updated_at)
SELECT
f.id,
m.new_user_id,
2, -- role = member
1, -- status = active
'data_fix_split',
NOW(),
NOW(),
NOW()
FROM tmp_device_user_mapping m
JOIN user_family f ON f.owner_user_id = m.original_user_id
AND f.status = 1 AND f.deleted_at IS NULL;
-- 7d. 更新 max_members如果原用户有 >2 个设备)
UPDATE user_family f
SET f.max_members = (
SELECT COUNT(*)
FROM user_family_member fm
WHERE fm.family_id = f.id AND fm.status = 1 AND fm.deleted_at IS NULL
)
WHERE f.owner_user_id IN (SELECT DISTINCT original_user_id FROM tmp_device_user_mapping)
AND f.status = 1 AND f.deleted_at IS NULL;
-- ============================================================
-- Step 8: 转移订阅(原用户的订阅保留,新用户不需要订阅)
-- 家庭成员共享 owner 的订阅,所以新用户不需要自己的订阅
-- 如果原用户已有订阅,新用户通过家庭组共享
-- ============================================================
-- (无需操作,代码中 familyBindingHelper.clearMemberSubscribes 会在 joinFamily 时清理)
-- 新用户是刚创建的,没有任何订阅记录,无需清理
-- ============================================================
-- Step 9: 验证修复结果
-- ============================================================
-- 9a. 确认没有用户拥有多个设备了
SELECT
d.user_id,
COUNT(*) as device_count,
GROUP_CONCAT(d.id ORDER BY d.id) as device_ids
FROM user_device d
WHERE d.enabled = 1
GROUP BY d.user_id
HAVING device_count > 1;
-- 预期结果0 行
-- 9b. 确认家庭组关系正确
SELECT
f.id as family_id,
f.owner_user_id,
f.max_members,
f.status as family_status,
GROUP_CONCAT(CONCAT(fm.user_id, '(role=', fm.role, ')') ORDER BY fm.role) as members
FROM user_family f
JOIN user_family_member fm ON fm.family_id = f.id AND fm.status = 1 AND fm.deleted_at IS NULL
WHERE f.owner_user_id IN (SELECT DISTINCT original_user_id FROM tmp_device_user_mapping)
AND f.status = 1 AND f.deleted_at IS NULL
GROUP BY f.id, f.owner_user_id, f.max_members, f.status;
-- 9c. 确认新用户都有 device auth_method
SELECT
m.new_user_id,
m.original_user_id,
m.identifier,
am.id as auth_method_id,
d.id as device_id,
d.user_id as device_user_id
FROM tmp_device_user_mapping m
JOIN user_auth_methods am ON am.user_id = m.new_user_id AND am.auth_type = 'device'
JOIN user_device d ON d.id = m.device_id;
-- ============================================================
-- 清理临时表
-- ============================================================
DROP TEMPORARY TABLE IF EXISTS tmp_devices_to_split;
DROP TEMPORARY TABLE IF EXISTS tmp_new_user_mapping;
DROP TEMPORARY TABLE IF EXISTS tmp_device_user_mapping;

View File

@ -0,0 +1,855 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// ── 模型定义(与项目一致) ──
type User struct {
Id int64 `gorm:"primaryKey"`
Password string `gorm:"type:varchar(255)"`
Algo string `gorm:"type:varchar(255)"`
Salt string `gorm:"type:varchar(255)"`
Avatar string `gorm:"type:varchar(255)"`
Balance int64 `gorm:"type:int"`
ReferCode string `gorm:"type:varchar(255)"`
RefererId int64 `gorm:"type:bigint"`
Commission int64 `gorm:"type:int"`
ReferralPercentage int64 `gorm:"type:int"`
OnlyFirstPurchase *bool `gorm:"type:tinyint(1)"`
GiftAmount int64 `gorm:"type:int"`
Enable *bool `gorm:"type:tinyint(1)"`
IsAdmin *bool `gorm:"type:tinyint(1)"`
EnableBalanceNotify *bool `gorm:"type:tinyint(1)"`
EnableLoginNotify *bool `gorm:"type:tinyint(1)"`
EnableSubscribeNotify *bool `gorm:"type:tinyint(1)"`
EnableTradeNotify *bool `gorm:"type:tinyint(1)"`
LastLoginTime *time.Time
MemberStatus string `gorm:"type:varchar(255)"`
Remark string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (User) TableName() string { return "user" }
type AuthMethod struct {
Id int64 `gorm:"primaryKey"`
UserId int64 `gorm:"type:bigint"`
AuthType string `gorm:"type:varchar(50)"`
AuthIdentifier string `gorm:"type:varchar(255)"`
Verified *bool `gorm:"type:tinyint(1)"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (AuthMethod) TableName() string { return "user_auth_methods" }
type Device struct {
Id int64 `gorm:"primaryKey"`
Ip string `gorm:"type:varchar(255)"`
UserId int64 `gorm:"type:bigint"`
UserAgent string `gorm:"type:text"`
Identifier string `gorm:"type:varchar(255)"`
ShortCode string `gorm:"type:varchar(50)"`
Online *bool `gorm:"type:tinyint(1)"`
Enabled *bool `gorm:"type:tinyint(1)"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (Device) TableName() string { return "user_device" }
type UserSubscribe struct {
Id int64 `gorm:"primaryKey"`
UserId int64 `gorm:"type:bigint"`
OrderId int64 `gorm:"type:bigint"`
SubscribeId int64 `gorm:"type:bigint"`
StartTime time.Time `gorm:"type:datetime(3)"`
ExpireTime *time.Time `gorm:"type:datetime(3)"`
FinishedAt *time.Time `gorm:"type:datetime"`
Traffic int64 `gorm:"type:bigint"`
Download int64 `gorm:"type:bigint"`
Upload int64 `gorm:"type:bigint"`
Token string `gorm:"type:varchar(255)"`
UUID string `gorm:"type:varchar(255)"`
Status uint8 `gorm:"type:tinyint(1)"`
Note string `gorm:"type:varchar(500)"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (UserSubscribe) TableName() string { return "user_subscribe" }
type Order struct {
Id int64 `gorm:"primaryKey"`
ParentId int64 `gorm:"type:bigint"`
UserId int64 `gorm:"type:bigint"`
SubscriptionUserId int64 `gorm:"type:bigint"`
OrderNo string `gorm:"type:varchar(255)"`
Type uint8 `gorm:"type:tinyint(1)"`
Quantity int64 `gorm:"type:bigint"`
Price int64 `gorm:"type:int"`
Amount int64 `gorm:"type:int"`
GiftAmount int64 `gorm:"type:int"`
Discount int64 `gorm:"type:int"`
Coupon string `gorm:"type:varchar(255)"`
CouponDiscount int64 `gorm:"type:int"`
Commission int64 `gorm:"type:int"`
PaymentId int64 `gorm:"type:bigint"`
Method string `gorm:"type:varchar(255)"`
FeeAmount int64 `gorm:"type:int"`
TradeNo string `gorm:"type:varchar(255)"`
Status uint8 `gorm:"type:tinyint(1)"`
SubscribeId int64 `gorm:"type:bigint"`
SubscribeToken string `gorm:"type:varchar(255)"`
AppAccountToken string `gorm:"type:varchar(36)"`
IsNew bool `gorm:"type:tinyint(1)"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (Order) TableName() string { return "order" }
type IAPTransaction struct {
Id int64 `gorm:"primaryKey"`
UserId int64 `gorm:"type:bigint"`
OriginalTransactionId string `gorm:"type:varchar(255)"`
TransactionId string `gorm:"type:varchar(255)"`
ProductId string `gorm:"type:varchar(255)"`
PurchaseAt *time.Time
RevocationAt *time.Time
JWSHash string `gorm:"type:varchar(255)"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (IAPTransaction) TableName() string { return "apple_iap_transactions" }
type Subscribe struct {
Id int64 `gorm:"primaryKey"`
Name string `gorm:"type:varchar(255)"`
Language string `gorm:"type:varchar(255)"`
Description string `gorm:"type:text"`
UnitPrice int64 `gorm:"type:int"`
UnitTime string `gorm:"type:varchar(255)"`
Discount string `gorm:"type:text"`
Replacement int64 `gorm:"type:int"`
Inventory int64 `gorm:"type:int"`
Traffic int64 `gorm:"type:int"`
SpeedLimit int64 `gorm:"type:int"`
DeviceLimit int64 `gorm:"type:int"`
Quota int64 `gorm:"type:int"`
NewUserOnly *bool `gorm:"type:tinyint(1)"`
Nodes string `gorm:"type:varchar(255)"`
NodeTags string `gorm:"type:varchar(255)"`
Show *bool `gorm:"type:tinyint(1)"`
Sell *bool `gorm:"type:tinyint(1)"`
Sort int64 `gorm:"type:int"`
DeductionRatio int64 `gorm:"type:int"`
AllowDeduction *bool `gorm:"type:tinyint(1)"`
ResetCycle int64 `gorm:"type:int"`
RenewalReset *bool `gorm:"type:tinyint(1)"`
ShowOriginalPrice bool `gorm:"type:tinyint(1)"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (Subscribe) TableName() string { return "subscribe" }
type Payment struct {
Id int64 `gorm:"primaryKey"`
Name string `gorm:"type:varchar(100)"`
Platform string `gorm:"type:varchar(100)"`
Icon string `gorm:"type:varchar(255)"`
Domain string `gorm:"type:varchar(255)"`
Config string `gorm:"type:text"`
Description string `gorm:"type:text"`
FeeMode uint `gorm:"type:tinyint(1)"`
FeePercent int64 `gorm:"type:int"`
FeeAmount int64 `gorm:"type:int"`
Enable *bool `gorm:"type:tinyint(1)"`
Token string `gorm:"type:varchar(255)"`
}
func (Payment) TableName() string { return "payment" }
type UserFamily struct {
Id int64 `gorm:"primaryKey"`
OwnerUserId int64 `gorm:"uniqueIndex"`
MaxMembers int64 `gorm:"default:3"`
Status uint8 `gorm:"type:tinyint(1);default:1"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (UserFamily) TableName() string { return "user_family" }
type UserFamilyMember struct {
Id int64 `gorm:"primaryKey"`
FamilyId int64
UserId int64 `gorm:"uniqueIndex"`
Role uint8 `gorm:"type:tinyint(1);default:2"`
Status uint8 `gorm:"type:tinyint(1);default:1"`
JoinSource string `gorm:"type:varchar(32)"`
JoinedAt time.Time
LeftAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (UserFamilyMember) TableName() string { return "user_family_member" }
// ── 主程序 ──
const (
defaultFamilyMaxSize = 3
orderStatusCompleted = 3
familyRoleOwner = 1
familyRoleMember = 2
batchSize = 100
)
func main() {
srcDSN := flag.String("src", "", "源数据库 DSN格式: user:password@tcp(host:port)/dbname")
dstDSN := flag.String("dst", "", "目标数据库 DSN格式: user:password@tcp(host:port)/dbname")
clean := flag.Bool("clean", false, "导入前清空目标库相关表")
dryRun := flag.Bool("dry-run", false, "仅分析不写入,打印统计信息")
flag.Parse()
if *srcDSN == "" || *dstDSN == "" {
fmt.Println("付费用户数据迁移工具")
fmt.Println()
fmt.Println("用法:")
fmt.Println(" go run scripts/migrate_paid_users.go \\")
fmt.Println(" -src 'root:rootpassword@tcp(127.0.0.1:3306)/ppanel?charset=utf8mb4&parseTime=True&loc=Local' \\")
fmt.Println(" -dst 'root:jpcV41ppanel@tcp(154.12.35.103:3306)/ppanel?charset=utf8mb4&parseTime=True&loc=Local' \\")
fmt.Println(" -clean")
fmt.Println()
fmt.Println("参数:")
fmt.Println(" -src 源数据库 DSN旧备份库")
fmt.Println(" -dst 目标数据库 DSN新线上库")
fmt.Println(" -clean 导入前清空目标库的用户/订单等表(保留表结构)")
fmt.Println(" -dry-run 仅分析不写入")
os.Exit(1)
}
gormCfg := &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
}
// 连接源库
fmt.Println("=== 付费用户数据迁移 ===")
fmt.Println()
fmt.Print("[1/10] 连接源数据库... ")
srcDB, err := gorm.Open(mysql.Open(*srcDSN), gormCfg)
if err != nil {
log.Fatalf("源库连接失败: %v", err)
}
fmt.Println("OK")
// 连接目标库
fmt.Print("[2/10] 连接目标数据库... ")
dstDB, err := gorm.Open(mysql.Open(*dstDSN), gormCfg)
if err != nil {
log.Fatalf("目标库连接失败: %v", err)
}
fmt.Println("OK")
// ── Step 3: 查询付费用户 ID ──
fmt.Print("[3/10] 查询付费用户... ")
var paidIDs []int64
err = srcDB.Raw(`
SELECT DISTINCT t.uid FROM (
SELECT user_id AS uid FROM ` + "`order`" + ` WHERE status = ? AND user_id > 0
UNION
SELECT user_id AS uid FROM apple_iap_transactions WHERE user_id > 0
) t
INNER JOIN user u ON u.id = t.uid
ORDER BY t.uid
`, orderStatusCompleted).Scan(&paidIDs).Error
if err != nil {
log.Fatalf("查询付费用户失败: %v", err)
}
fmt.Printf("%d 个付费用户\n", len(paidIDs))
if len(paidIDs) == 0 {
fmt.Println("没有找到付费用户,退出")
return
}
// ── Step 4: 读取源库数据 ──
fmt.Print("[4/10] 读取源库数据... ")
var (
users []User
auths []AuthMethod
devices []Device
orders []Order
subscribes []UserSubscribe
iaps []IAPTransaction
// 全量表
subPlans []Subscribe
payments []Payment
)
srcDB.Where("id IN ?", paidIDs).Find(&users)
srcDB.Where("user_id IN ?", paidIDs).Find(&auths)
srcDB.Where("user_id IN ?", paidIDs).Find(&devices)
srcDB.Where("user_id IN ? AND status = ?", paidIDs, orderStatusCompleted).Find(&orders)
srcDB.Where("user_id IN ?", paidIDs).Find(&subscribes)
srcDB.Where("user_id IN ?", paidIDs).Find(&iaps)
srcDB.Find(&subPlans)
srcDB.Find(&payments)
// ── 处理多订阅:如果用户有多个订阅,仅保留未过期的 ──
nowTime := time.Now()
subByUser := make(map[int64][]UserSubscribe)
for _, s := range subscribes {
subByUser[s.UserId] = append(subByUser[s.UserId], s)
}
var validSubscribes []UserSubscribe
for _, subs := range subByUser {
if len(subs) <= 1 {
// 单个订阅直接保留
validSubscribes = append(validSubscribes, subs...)
continue
}
var unexpired []UserSubscribe
var latest *UserSubscribe
for i := range subs {
s := subs[i]
// 如果没有过期时间,或者过期时间在当前时间之后
if s.ExpireTime == nil || s.ExpireTime.After(nowTime) {
unexpired = append(unexpired, s)
}
// 记录到期时间最晚的一个,以防全部都过期了
if latest == nil {
latest = &s
} else if latest.ExpireTime != nil && s.ExpireTime != nil && s.ExpireTime.After(*latest.ExpireTime) {
latest = &s
} else if latest.ExpireTime != nil && s.ExpireTime == nil {
latest = &s
}
}
if len(unexpired) > 0 {
// 存在未过期的订阅,仅保留所有未过期的
validSubscribes = append(validSubscribes, unexpired...)
} else if latest != nil {
// 如果全部过期,仅保留到期时间最晚的那一个
validSubscribes = append(validSubscribes, *latest)
}
}
subscribes = validSubscribes
fmt.Println("OK")
fmt.Println()
fmt.Println(" 数据统计:")
fmt.Printf(" user: %d\n", len(users))
fmt.Printf(" user_auth_methods: %d\n", len(auths))
fmt.Printf(" user_device: %d\n", len(devices))
fmt.Printf(" order: %d\n", len(orders))
fmt.Printf(" user_subscribe: %d\n", len(subscribes))
fmt.Printf(" apple_iap: %d\n", len(iaps))
fmt.Printf(" subscribe(全量): %d\n", len(subPlans))
fmt.Printf(" payment(全量): %d\n", len(payments))
// ── 识别多设备用户 ──
deviceByUser := make(map[int64][]Device)
for _, d := range devices {
deviceByUser[d.UserId] = append(deviceByUser[d.UserId], d)
}
authByUserDevice := make(map[string]*AuthMethod) // key: "userId:identifier"
for i := range auths {
a := &auths[i]
if a.AuthType == "device" {
key := fmt.Sprintf("%d:%s", a.UserId, a.AuthIdentifier)
authByUserDevice[key] = a
}
}
type splitInfo struct {
OwnerUID int64
Device Device
AuthMethod *AuthMethod
}
var splits []splitInfo
for uid, devs := range deviceByUser {
if len(devs) <= 1 {
continue
}
// 按 ID 排序:最小的保留,其余拆分
minID := devs[0].Id
for _, d := range devs[1:] {
if d.Id < minID {
minID = d.Id
}
}
for _, d := range devs {
if d.Id == minID {
continue
}
key := fmt.Sprintf("%d:%s", uid, d.Identifier)
auth := authByUserDevice[key]
splits = append(splits, splitInfo{
OwnerUID: uid,
Device: d,
AuthMethod: auth,
})
}
}
fmt.Printf("\n 多设备拆分: %d 个设备 → 独立用户\n", len(splits))
fmt.Println()
// ── ID 重建:将所有记录主键从 1 开始连续重赋值,并同步更新所有外键 ──
fmt.Println(" 重建 ID...")
// 各表 old→new 映射(仅对有外键引用的表建立映射)
userIDMap := make(map[int64]int64, len(users))
orderIDMap := make(map[int64]int64, len(orders))
subPlanIDMap := make(map[int64]int64, len(subPlans))
paymentIDMap := make(map[int64]int64, len(payments))
deviceIDMap := make(map[int64]int64, len(devices))
// 1. 重建 user ID从 1 开始连续)
for i := range users {
newID := int64(i + 1)
userIDMap[users[i].Id] = newID
users[i].Id = newID
}
// 2. 重建 subscribe 配置表 ID
for i := range subPlans {
newID := int64(i + 1)
subPlanIDMap[subPlans[i].Id] = newID
subPlans[i].Id = newID
}
// 3. 重建 payment 配置表 ID
for i := range payments {
newID := int64(i + 1)
paymentIDMap[payments[i].Id] = newID
payments[i].Id = newID
}
// 4. 重建 auth_methods ID + 更新 user_id 外键
for i := range auths {
auths[i].Id = int64(i + 1)
if v, ok := userIDMap[auths[i].UserId]; ok {
auths[i].UserId = v
}
}
// 5. 重建 device ID + 更新 user_id 外键
for i := range devices {
newID := int64(i + 1)
deviceIDMap[devices[i].Id] = newID
devices[i].Id = newID
if v, ok := userIDMap[devices[i].UserId]; ok {
devices[i].UserId = v
}
}
// 6. 重建 order ID + 外键user_id / subscription_user_id / payment_id / subscribe_id
for i := range orders {
newID := int64(i + 1)
orderIDMap[orders[i].Id] = newID
orders[i].Id = newID
if v, ok := userIDMap[orders[i].UserId]; ok {
orders[i].UserId = v
}
if orders[i].SubscriptionUserId > 0 {
if v, ok := userIDMap[orders[i].SubscriptionUserId]; ok {
orders[i].SubscriptionUserId = v
}
}
if orders[i].PaymentId > 0 {
if v, ok := paymentIDMap[orders[i].PaymentId]; ok {
orders[i].PaymentId = v
}
}
if orders[i].SubscribeId > 0 {
if v, ok := subPlanIDMap[orders[i].SubscribeId]; ok {
orders[i].SubscribeId = v
}
}
}
// 二次处理 order.ParentId父子订单指向同表需在 orderIDMap 完整建立后再处理)
for i := range orders {
if orders[i].ParentId > 0 {
if v, ok := orderIDMap[orders[i].ParentId]; ok {
orders[i].ParentId = v
}
}
}
// 7. 重建 user_subscribe ID + 外键
for i := range subscribes {
subscribes[i].Id = int64(i + 1)
if v, ok := userIDMap[subscribes[i].UserId]; ok {
subscribes[i].UserId = v
}
if subscribes[i].OrderId > 0 {
if v, ok := orderIDMap[subscribes[i].OrderId]; ok {
subscribes[i].OrderId = v
}
}
if subscribes[i].SubscribeId > 0 {
if v, ok := subPlanIDMap[subscribes[i].SubscribeId]; ok {
subscribes[i].SubscribeId = v
}
}
}
// 8. 重建 iap ID + 更新 user_id 外键
for i := range iaps {
iaps[i].Id = int64(i + 1)
if v, ok := userIDMap[iaps[i].UserId]; ok {
iaps[i].UserId = v
}
}
// 9. 更新 paidIDsStep 8 家庭组创建使用新 user ID
for i, uid := range paidIDs {
if v, ok := userIDMap[uid]; ok {
paidIDs[i] = v
}
}
// 10. 更新 splits 中的 OwnerUID 和 Device 副本
// Device 是值拷贝,需通过 deviceIDMap 单独更新AuthMethod 是指针,已随 auths[i] 同步
for i := range splits {
if v, ok := userIDMap[splits[i].OwnerUID]; ok {
splits[i].OwnerUID = v
}
if v, ok := deviceIDMap[splits[i].Device.Id]; ok {
splits[i].Device.Id = v
}
if v, ok := userIDMap[splits[i].Device.UserId]; ok {
splits[i].Device.UserId = v
}
}
fmt.Printf(" OK — user:%d auth:%d device:%d order:%d subscribe:%d iap:%d\n",
len(userIDMap), len(auths), len(deviceIDMap), len(orderIDMap), len(subscribes), len(iaps))
// ── 注入默认管理员用户devneeds52@gmail.com ──
// 该用户不涉及付费订单ID 紧接在已迁移用户之后,避免冲突
{
defaultCreatedAt := time.Date(2025, 9, 30, 9, 33, 45, 780_000_000, time.UTC)
lastLogin := time.Date(2026, 3, 15, 17, 13, 45, 0, time.UTC)
defaultUID := int64(len(users) + 1)
defaultUser := User{
Id: defaultUID,
Password: "$pbkdf2-sha512$kyFSMS4eAnupW7bX$38953ce0e7ec8415c39603bdc3010050ddab2e433f0383222215bbec013450e3",
Algo: "default",
Salt: "default",
Avatar: "",
Balance: 0,
ReferCode: "uuEPXVjS",
Commission: 0,
ReferralPercentage: 0,
OnlyFirstPurchase: boolPtr(true),
GiftAmount: 0,
Enable: boolPtr(true),
IsAdmin: boolPtr(true),
EnableBalanceNotify: boolPtr(false),
EnableLoginNotify: boolPtr(false),
EnableSubscribeNotify: boolPtr(false),
EnableTradeNotify: boolPtr(false),
LastLoginTime: &lastLogin,
MemberStatus: "",
Remark: "",
CreatedAt: defaultCreatedAt,
UpdatedAt: time.Now(),
}
users = append(users, defaultUser)
defaultAuth := AuthMethod{
Id: int64(len(auths) + 1),
UserId: defaultUID,
AuthType: "email",
AuthIdentifier: "devneeds52@gmail.com",
Verified: boolPtr(true),
CreatedAt: defaultCreatedAt,
UpdatedAt: defaultCreatedAt,
}
auths = append(auths, defaultAuth)
fmt.Printf(" 注入管理员: uid=%d email=devneeds52@gmail.com\n", defaultUID)
}
fmt.Println()
if *dryRun {
fmt.Println("[DRY-RUN] 仅分析,不写入目标库")
return
}
// ── Step 5: 清空目标库(可选) ──
if *clean {
fmt.Print("[5/10] 清空目标库... ")
dstDB.Exec("SET FOREIGN_KEY_CHECKS = 0")
for _, tbl := range []string{
"user", "user_auth_methods", "user_device",
"`order`", "user_subscribe", "apple_iap_transactions",
"user_family", "user_family_member",
} {
dstDB.Exec(fmt.Sprintf("TRUNCATE TABLE %s", tbl))
}
dstDB.Exec("SET FOREIGN_KEY_CHECKS = 1")
fmt.Println("OK")
} else {
fmt.Println("[5/10] 跳过清空(未指定 -clean")
}
// ── Step 6: 写入全量配置表 ──
fmt.Print("[6/10] 写入全量配置表... ")
if len(subPlans) > 0 {
dstDB.Exec("DELETE FROM subscribe") // 先清再插
for _, s := range subPlans {
dstDB.Create(&s)
}
}
if len(payments) > 0 {
dstDB.Exec("DELETE FROM payment")
for _, p := range payments {
dstDB.Create(&p)
}
}
fmt.Println("OK")
// ── Step 7: 写入付费用户数据(事务) ──
fmt.Print("[7/10] 写入付费用户数据... ")
err = dstDB.Transaction(func(tx *gorm.DB) error {
tx.Exec("SET FOREIGN_KEY_CHECKS = 0")
if err := tx.CreateInBatches(&users, batchSize).Error; err != nil {
return fmt.Errorf("写入 user 失败: %w", err)
}
if err := tx.CreateInBatches(&auths, batchSize).Error; err != nil {
return fmt.Errorf("写入 auth_methods 失败: %w", err)
}
if err := tx.CreateInBatches(&devices, batchSize).Error; err != nil {
return fmt.Errorf("写入 device 失败: %w", err)
}
if err := tx.CreateInBatches(&orders, batchSize).Error; err != nil {
return fmt.Errorf("写入 order 失败: %w", err)
}
if err := tx.CreateInBatches(&subscribes, batchSize).Error; err != nil {
return fmt.Errorf("写入 subscribe 失败: %w", err)
}
if err := tx.CreateInBatches(&iaps, batchSize).Error; err != nil {
return fmt.Errorf("写入 iap 失败: %w", err)
}
tx.Exec("SET FOREIGN_KEY_CHECKS = 1")
return nil
})
if err != nil {
log.Fatalf("写入失败: %v", err)
}
fmt.Println("OK")
// ── Step 8: 创建家庭组 ──
fmt.Print("[8/10] 创建家庭组... ")
now := time.Now()
familyCount := 0
err = dstDB.Transaction(func(tx *gorm.DB) error {
for _, uid := range paidIDs {
// 只为多设备用户创建家庭组
if len(deviceByUser[uid]) <= 1 {
continue
}
family := UserFamily{
OwnerUserId: uid,
MaxMembers: defaultFamilyMaxSize,
Status: 1,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(&family).Error; err != nil {
return fmt.Errorf("创建家庭组(uid=%d)失败: %w", uid, err)
}
member := UserFamilyMember{
FamilyId: family.Id,
UserId: uid,
Role: familyRoleOwner,
Status: 1,
JoinSource: "migration",
JoinedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(&member).Error; err != nil {
return fmt.Errorf("创建家主成员(uid=%d)失败: %w", uid, err)
}
familyCount++
}
return nil
})
if err != nil {
log.Fatalf("家庭组创建失败: %v", err)
}
fmt.Printf("%d 个家庭组\n", familyCount)
// ── Step 9: 多设备拆分 ──
fmt.Print("[9/10] 多设备拆分... ")
splitCount := 0
err = dstDB.Transaction(func(tx *gorm.DB) error {
for _, s := range splits {
// 1. 创建新用户
newUser := User{
Password: "",
Algo: "default",
Salt: "default",
Enable: boolPtr(true),
IsAdmin: boolPtr(false),
OnlyFirstPurchase: boolPtr(true),
EnableBalanceNotify: boolPtr(false),
EnableLoginNotify: boolPtr(false),
EnableSubscribeNotify: boolPtr(false),
EnableTradeNotify: boolPtr(false),
CreatedAt: s.Device.CreatedAt,
UpdatedAt: now,
}
if err := tx.Create(&newUser).Error; err != nil {
return fmt.Errorf("创建拆分用户失败(owner=%d, device=%d): %w", s.OwnerUID, s.Device.Id, err)
}
// 2. 转移设备到新用户
if err := tx.Model(&Device{}).Where("id = ?", s.Device.Id).
Update("user_id", newUser.Id).Error; err != nil {
return fmt.Errorf("转移设备失败: %w", err)
}
// 3. 转移 auth_method 到新用户
if s.AuthMethod != nil {
if err := tx.Model(&AuthMethod{}).Where("id = ?", s.AuthMethod.Id).
Update("user_id", newUser.Id).Error; err != nil {
return fmt.Errorf("转移 auth_method 失败: %w", err)
}
}
// 4. 查找原用户的家庭组(如果不存在则创建,虽然理论上 Step 8 已经为多设备用户创建了)
var family UserFamily
if err := tx.Where("owner_user_id = ?", s.OwnerUID).First(&family).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// 补救措施:为该用户创建一个家庭组
family = UserFamily{
OwnerUserId: s.OwnerUID,
MaxMembers: defaultFamilyMaxSize,
Status: 1,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(&family).Error; err != nil {
return fmt.Errorf("创建家庭组补救失败(owner=%d): %w", s.OwnerUID, err)
}
// 创建家主成员
ownerMember := UserFamilyMember{
FamilyId: family.Id,
UserId: s.OwnerUID,
Role: familyRoleOwner,
Status: 1,
JoinSource: "migration_split_recovery",
JoinedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(&ownerMember).Error; err != nil {
return fmt.Errorf("创建家主成员补救失败(owner=%d): %w", s.OwnerUID, err)
}
familyCount++ // 更新计数器
} else {
return fmt.Errorf("查找家庭组失败(owner=%d): %w", s.OwnerUID, err)
}
}
// 5. 加入家庭组
member := UserFamilyMember{
FamilyId: family.Id,
UserId: newUser.Id,
Role: familyRoleMember,
Status: 1,
JoinSource: "migration_split",
JoinedAt: s.Device.CreatedAt,
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.Create(&member).Error; err != nil {
return fmt.Errorf("添加家庭成员失败: %w", err)
}
splitCount++
}
return nil
})
if err != nil {
log.Fatalf("设备拆分失败: %v", err)
}
fmt.Printf("%d 个设备\n", splitCount)
// ── Step 10: 修复各表 AUTO_INCREMENT ──
// 确保迁移后新写入的记录不会触发主键冲突
fmt.Print("[10/10] 修复 AUTO_INCREMENT... ")
type autoIncTable struct {
table string // 表名(不含反引号)
quoted string // SQL 中使用的表名(含反引号)
}
autoIncTables := []autoIncTable{
{"user", "`user`"},
{"user_auth_methods", "`user_auth_methods`"},
{"user_device", "`user_device`"},
{"order", "`order`"},
{"user_subscribe", "`user_subscribe`"},
{"apple_iap_transactions", "`apple_iap_transactions`"},
{"user_family", "`user_family`"},
{"user_family_member", "`user_family_member`"},
}
for _, t := range autoIncTables {
var maxID int64
dstDB.Raw(fmt.Sprintf("SELECT COALESCE(MAX(id), 0) FROM %s", t.quoted)).Scan(&maxID)
nextID := maxID + 1
if err := dstDB.Exec(fmt.Sprintf("ALTER TABLE %s AUTO_INCREMENT = %d", t.quoted, nextID)).Error; err != nil {
log.Printf(" 警告: 修复 %s AUTO_INCREMENT 失败: %v", t.table, err)
} else {
fmt.Printf("\n %-30s MAX(id)=%-8d AUTO_INCREMENT→%d", t.table, maxID, nextID)
}
}
fmt.Println("\nOK")
// ── 结果 ──
fmt.Println()
fmt.Println("=== 迁移完成 ===")
fmt.Printf(" 用户: %d (原始) + %d (拆分) = %d\n", len(users), splitCount, len(users)+splitCount)
fmt.Printf(" 家庭组: %d\n", familyCount)
fmt.Printf(" 家庭成员: %d (家主) + %d (拆分) = %d\n", familyCount, splitCount, familyCount+splitCount)
fmt.Printf(" 订单: %d\n", len(orders))
fmt.Printf(" 订阅: %d\n", len(subscribes))
}
func boolPtr(b bool) *bool { return &b }

BIN
scripts/output/migrate_paid_users Executable file

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

33
说明文档.md Normal file
View File

@ -0,0 +1,33 @@
# 说明文档.md
## 项目规划
本任务旨在帮助用户在云端服务器上替换 SSL 证书。用户已解压 `airoport.co_ecc.zip`,需将得到的 `.cer``.key` 文件替换到 `/etc/letsencrypt/archive/airoport.co` 目录下对应的 `.pem` 文件中。
## 实施方案
1. **文件对应关系确认**:映射解压后的文件与 `archive` 目录下的 PEM 文件。
2. **备份与替换**:备份旧证书,执行 `cp` 命令覆盖现有文件。
3. **服务重启建议**:提示用户替换后需重启 Nginx/Gateway 服务以使证书生效。
## 进度记录
| 时间节点 | 任务说明 | 进度 | 结果说明 |
| :--- | :--- | :--- | :--- |
| 2026-03-11 | 初始化文档并提供指令 | [x] 已完成 | 已提供查找日志的命令并记录在文档中 |
| 2026-03-11 | 提供今天所有 ERROR 报错指令 | [x] 已完成 | 已提供根据日期过滤 ERROR 的命令 |
| 2026-03-12 | 分析并确认 Unknown column 错误 | [x] 已完成 | 确认为 `user_device` 缺少 `short_code` 字段,已提供 SQL |
| 2026-03-12 | 提供 SSL 证书替换指令 | [x] 已完成 | 已提供备份与替换证书的组合指令 |
certbot certonly --manual --preferred-challenges dns -d airoport.win -d "*.airoport.win" -d hifastapp.com
gunzip -c mysql_dump_20260317_145137.sql.gz \
| docker exec -i ppanel-mysql mysql -uroot -pjpcV41ppanel
go run scripts/migrate_paid_users.go -src 'root:rootpassword@tcp(127.0.0.1:3306)/ppanel?charset=utf8mb4&parseTime=True&loc=Local' -dst 'root:jpcV41ppanel@tcp(154.12.35.103:3306)/ppanel?charset=utf8mb4&parseTime=True&loc=Local' -clean
docker exec ppanel-redis redis-cli --scan --pattern "*" \
grep -vE "^auth:session_id:|^auth:user_sessions:" \
xargs -r -n 500 docker exec -i ppanel-redis redis-cli DEL