Compare commits

...

20 Commits

Author SHA1 Message Date
shanshanzhong147 282e1e4087 deviceno
Build docker and publish / build (20.15.1) (push) Successful in 7m26s
2026-03-17 09:12:56 -07:00
shanshanzhong147 4b528184ea devicd_no
Build docker and publish / build (20.15.1) (push) Successful in 7m58s
2026-03-17 08:37:36 -07:00
shanshanzhong147 dcdbabdb13 注销
Build docker and publish / build (20.15.1) (push) Successful in 7m34s
2026-03-17 07:12:42 -07:00
shanshanzhong147 3cd22d8538 x
Build docker and publish / build (20.15.1) (push) Successful in 8m3s
2026-03-16 00:59:49 -07:00
shanshanzhong147 1d2f2a594a feat: 增强用户搜索功能,支持通过设备ID哈希进行查找。
Build docker and publish / build (20.15.1) (push) Successful in 8m42s
2026-03-15 22:02:23 -07:00
shanshanzhong147 79a1eeca1f feat: 自动生成并更新用户缺失的推荐码
Build docker and publish / build (20.15.1) (push) Successful in 7m33s
2026-03-15 19:43:53 -07:00
shanshanzhong147 3d732c0361 feat: 为用户设备添加 DeviceNo 字段并实现 ID 混淆,同时在迁移脚本中新增 ID 重建逻辑。
Build docker and publish / build (20.15.1) (push) Has been cancelled
2026-03-15 19:38:37 -07:00
shanshanzhong147 3db14ae472 feat: 迁移订阅表字段,为用户认证方法添加软删除功能及索引,并调整 Docker Compose 中 MySQL 端口映射。
Build docker and publish / build (20.15.1) (push) Successful in 7m34s
2026-03-15 01:47:01 -07:00
shanshanzhong147 ad578883e4 feat: 添加付费用户数据迁移脚本、报告及相关管理逻辑调整。
Build docker and publish / build (20.15.1) (push) Successful in 7m49s
2026-03-14 22:37:03 -07:00
shanshanzhong147 2cc1124dd8 fix: 任何人注销账号都解散整个家庭组,删除所有成员的登录方式和订阅
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
shanshanzhong147 b6405c8f28 fix: IAP attach 后立即清除订阅缓存,修复 App 查到旧数据;返回值改用累计到期时间
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
shanshanzhong147 add27aa4d9 fix: Apple IAP attach 支持家庭成员购买场景
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
shanshanzhong147 2f33e1e680 fix: 踢其他设备时不再清除踢人者自己的 session
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
shanshanzhong147 e4ec85c176 fix: clearAllSessions 改用 SCAN 查找 session,修复会话清理无效
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
shanshanzhong147 dddb8c7fe2 fix: 平级踢人后更新被踢设备 online 状态为 false
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
shanshanzhong147 384c8df506 fix: 踢出用户时清除所有 session,确保旧 token 立即失效
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
shanshanzhong147 9b6efe2901 DefaultFamilyMaxSize
Build docker and publish / build (20.15.1) (push) Successful in 7m32s
2026-03-12 01:41:39 -07:00
shanshanzhong147 7d5b4fcb84 邮箱修复
Build docker and publish / build (20.15.1) (push) Successful in 7m13s
2026-03-12 01:24:21 -07:00
shanshanzhong147 a3cc23bbd4 feat: 绑定新邮箱时创建独立邮箱用户并转移订阅,而非挂在设备用户上
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
shanshanzhong147 ec39579c39 fix: 注册 /v1/common/client/download 路由,修复客户端下载链接 404
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
+2
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"`
+1 -1
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"
+4
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 # 后台登录密码,请修改为强密码
@@ -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;
@@ -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`;
+53
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
+6 -5
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 {
+3
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))
@@ -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).
@@ -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,
@@ -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)
}
@@ -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
@@ -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
}
@@ -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)),
)
}
+5 -1
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"
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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
}
@@ -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,
@@ -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
+92 -24
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)),
)
}
+30 -22
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
+1 -1
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 {
+11 -2
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").
+2
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"`
+82 -2
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
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
}
+17 -8
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 == "" {
+1
View File
@@ -10,6 +10,7 @@ const (
EmailTypeMaintenance = "maintenance"
EmailTypeExpiration = "expiration"
EmailTypeTrafficExceed = "traffic_exceed"
EmailTypeDeleteAccount = "delete_account"
EmailTypeCustom = "custom"
)
+319
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 条 |
+109
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
+283
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;
+855
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
View 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
+12 -64
View File
File diff suppressed because one or more lines are too long
+33
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