Compare commits
20 Commits
b3c721ee58
...
282e1e4087
| Author | SHA1 | Date | |
|---|---|---|---|
| 282e1e4087 | |||
| 4b528184ea | |||
| dcdbabdb13 | |||
| 3cd22d8538 | |||
| 1d2f2a594a | |||
| 79a1eeca1f | |||
| 3d732c0361 | |||
| 3db14ae472 | |||
| ad578883e4 | |||
| 2cc1124dd8 | |||
| b6405c8f28 | |||
| add27aa4d9 | |||
| 2f33e1e680 | |||
| e4ec85c176 | |||
| dddb8c7fe2 | |||
| 384c8df506 | |||
| 9b6efe2901 | |||
| 7d5b4fcb84 | |||
| a3cc23bbd4 | |||
| ec39579c39 |
@ -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"`
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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`;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -13,7 +13,7 @@ const (
|
||||
FamilyMemberActive uint8 = 1
|
||||
FamilyMemberLeft uint8 = 2
|
||||
FamilyMemberRemoved uint8 = 3
|
||||
DefaultFamilyMaxSize int64 = 2
|
||||
DefaultFamilyMaxSize int64 = 3
|
||||
)
|
||||
|
||||
type UserFamily struct {
|
||||
|
||||
@ -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_no(8位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").
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -27,12 +27,12 @@ const (
|
||||
<tr>
|
||||
<td style="padding:0 43px 0 43px;">
|
||||
<h1 style="font-weight:600; font-size:36px; line-height:1.4; color:#0F2C53; margin:0 0 24px 0;">
|
||||
注销验证
|
||||
验证码
|
||||
</h1>
|
||||
<p style="font-weight:400; font-size:16px; line-height:1.4; color:#0F2C53; margin:0 0 24px 0; white-space:pre-line;">
|
||||
亲爱的用户,
|
||||
|
||||
我们收到了你在{{.SiteName}}的注销请求
|
||||
你正在进行{{.SiteName}}账户相关操作
|
||||
请在系统提示时输入以下验证码:
|
||||
</p>
|
||||
|
||||
@ -299,5 +299,85 @@ const (
|
||||
<div class="footer">此为系统邮件,请勿回复</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
DefaultDeleteAccountEmailTemplate = `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f4f4f4; font-family:'PingFang SC','Microsoft YaHei',Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#f4f4f4">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0" bgcolor="#ffffff" style="min-height:740px; padding-bottom:53px;">
|
||||
|
||||
<!-- Top Logo -->
|
||||
<tr>
|
||||
<td align="right" style="padding:32px 40px 0 0;">
|
||||
{{if .SiteLogo}}<img height="47" style="display:block;" src="{{.SiteLogo}}" alt="{{.SiteName}}">{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Spacer -->
|
||||
<tr><td height="70"></td></tr>
|
||||
|
||||
<!-- Main Content -->
|
||||
<tr>
|
||||
<td style="padding:0 43px 0 43px;">
|
||||
<h1 style="font-weight:600; font-size:36px; line-height:1.4; color:#0F2C53; margin:0 0 24px 0;">
|
||||
账户注销确认
|
||||
</h1>
|
||||
<p style="font-weight:400; font-size:16px; line-height:1.4; color:#0F2C53; margin:0 0 24px 0; white-space:pre-line;">
|
||||
亲爱的用户,
|
||||
|
||||
我们收到了你注销 {{.SiteName}} 账户的请求。
|
||||
请在系统提示时输入以下验证码以完成注销:
|
||||
</p>
|
||||
|
||||
<div style="font-weight:600; font-size:48px; line-height:1.4; color:#0F2C53; margin:24px 0; letter-spacing:2px;">
|
||||
{{.Code}}
|
||||
</div>
|
||||
|
||||
<p style="font-weight:400; font-size:16px; line-height:1.4; color:#0F2C53; margin:24px 0 0 0;">
|
||||
该验证码将在 {{.Expire}} 分钟 后过期。
|
||||
</p>
|
||||
|
||||
<!-- Warning Box -->
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0;">
|
||||
<tr>
|
||||
<td style="background:#FFF3CD; border-left:4px solid #FF8C00; padding:16px 20px; border-radius:4px;">
|
||||
<p style="margin:0; font-size:15px; color:#5C3D00; line-height:1.6;">
|
||||
⚠️ <strong>重要提示:</strong>账户注销后,你的所有数据(包括订阅、套餐、账户信息)将被<strong>永久删除且不可恢复</strong>。请确认你已充分了解此操作的后果。
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="font-weight:400; font-size:16px; line-height:1.4; color:#0F2C53; margin:24px 0 0 0; white-space:pre-line;">
|
||||
如果这不是你本人操作,请忽略本邮件,你的账户将保持安全。
|
||||
|
||||
谢谢,
|
||||
{{.SiteName}}团队
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Spacer -->
|
||||
<tr><td height="50"></td></tr>
|
||||
|
||||
<!-- Footer Logo -->
|
||||
<tr>
|
||||
<td align="center" style="padding-bottom:20px;">
|
||||
{{if .SiteLogo}}<img width="52" height="52" style="display:block; margin:0 auto;" src="{{.SiteLogo}}" alt="{{.SiteName}}">{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
)
|
||||
|
||||
25
pkg/tool/device.go
Normal file
25
pkg/tool/device.go
Normal 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
|
||||
}
|
||||
@ -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 == "" {
|
||||
|
||||
@ -10,6 +10,7 @@ const (
|
||||
EmailTypeMaintenance = "maintenance"
|
||||
EmailTypeExpiration = "expiration"
|
||||
EmailTypeTrafficExceed = "traffic_exceed"
|
||||
EmailTypeDeleteAccount = "delete_account"
|
||||
EmailTypeCustom = "custom"
|
||||
)
|
||||
|
||||
|
||||
319
scripts/MIGRATION_REPORT.md
Normal file
319
scripts/MIGRATION_REPORT.md
Normal 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
scripts/fix_orphan_devices_family.sql
Normal file
109
scripts/fix_orphan_devices_family.sql
Normal 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_member(owner 身份)
|
||||
-- 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
scripts/fix_split_multi_device_users.sql
Normal file
283
scripts/fix_split_multi_device_users.sql
Normal 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 代码用 Base62,SQL 里用 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
scripts/migrate_paid_users.go
Normal file
855
scripts/migrate_paid_users.go
Normal 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. 更新 paidIDs(Step 8 家庭组创建使用新 user ID)
|
||||
for i, uid := range paidIDs {
|
||||
if v, ok := userIDMap[uid]; ok {
|
||||
paidIDs[i] = v
|
||||
}
|
||||
}
|
||||
|
||||
// 10. 更新 splits 中的 OwnerUID 和 Device 副本
|
||||
// Device 是值拷贝,需通过 deviceIDMap 单独更新;AuthMethod 是指针,已随 auths[i] 同步
|
||||
for i := range splits {
|
||||
if v, ok := userIDMap[splits[i].OwnerUID]; ok {
|
||||
splits[i].OwnerUID = v
|
||||
}
|
||||
if v, ok := deviceIDMap[splits[i].Device.Id]; ok {
|
||||
splits[i].Device.Id = v
|
||||
}
|
||||
if v, ok := userIDMap[splits[i].Device.UserId]; ok {
|
||||
splits[i].Device.UserId = v
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf(" OK — user:%d auth:%d device:%d order:%d subscribe:%d iap:%d\n",
|
||||
len(userIDMap), len(auths), len(deviceIDMap), len(orderIDMap), len(subscribes), len(iaps))
|
||||
|
||||
// ── 注入默认管理员用户(devneeds52@gmail.com) ──
|
||||
// 该用户不涉及付费订单,ID 紧接在已迁移用户之后,避免冲突
|
||||
{
|
||||
defaultCreatedAt := time.Date(2025, 9, 30, 9, 33, 45, 780_000_000, time.UTC)
|
||||
lastLogin := time.Date(2026, 3, 15, 17, 13, 45, 0, time.UTC)
|
||||
defaultUID := int64(len(users) + 1)
|
||||
|
||||
defaultUser := User{
|
||||
Id: defaultUID,
|
||||
Password: "$pbkdf2-sha512$kyFSMS4eAnupW7bX$38953ce0e7ec8415c39603bdc3010050ddab2e433f0383222215bbec013450e3",
|
||||
Algo: "default",
|
||||
Salt: "default",
|
||||
Avatar: "",
|
||||
Balance: 0,
|
||||
ReferCode: "uuEPXVjS",
|
||||
Commission: 0,
|
||||
ReferralPercentage: 0,
|
||||
OnlyFirstPurchase: boolPtr(true),
|
||||
GiftAmount: 0,
|
||||
Enable: boolPtr(true),
|
||||
IsAdmin: boolPtr(true),
|
||||
EnableBalanceNotify: boolPtr(false),
|
||||
EnableLoginNotify: boolPtr(false),
|
||||
EnableSubscribeNotify: boolPtr(false),
|
||||
EnableTradeNotify: boolPtr(false),
|
||||
LastLoginTime: &lastLogin,
|
||||
MemberStatus: "",
|
||||
Remark: "",
|
||||
CreatedAt: defaultCreatedAt,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
users = append(users, defaultUser)
|
||||
|
||||
defaultAuth := AuthMethod{
|
||||
Id: int64(len(auths) + 1),
|
||||
UserId: defaultUID,
|
||||
AuthType: "email",
|
||||
AuthIdentifier: "devneeds52@gmail.com",
|
||||
Verified: boolPtr(true),
|
||||
CreatedAt: defaultCreatedAt,
|
||||
UpdatedAt: defaultCreatedAt,
|
||||
}
|
||||
auths = append(auths, defaultAuth)
|
||||
|
||||
fmt.Printf(" 注入管理员: uid=%d email=devneeds52@gmail.com\n", defaultUID)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
if *dryRun {
|
||||
fmt.Println("[DRY-RUN] 仅分析,不写入目标库")
|
||||
return
|
||||
}
|
||||
|
||||
// ── Step 5: 清空目标库(可选) ──
|
||||
if *clean {
|
||||
fmt.Print("[5/10] 清空目标库... ")
|
||||
dstDB.Exec("SET FOREIGN_KEY_CHECKS = 0")
|
||||
for _, tbl := range []string{
|
||||
"user", "user_auth_methods", "user_device",
|
||||
"`order`", "user_subscribe", "apple_iap_transactions",
|
||||
"user_family", "user_family_member",
|
||||
} {
|
||||
dstDB.Exec(fmt.Sprintf("TRUNCATE TABLE %s", tbl))
|
||||
}
|
||||
dstDB.Exec("SET FOREIGN_KEY_CHECKS = 1")
|
||||
fmt.Println("OK")
|
||||
} else {
|
||||
fmt.Println("[5/10] 跳过清空(未指定 -clean)")
|
||||
}
|
||||
|
||||
// ── Step 6: 写入全量配置表 ──
|
||||
fmt.Print("[6/10] 写入全量配置表... ")
|
||||
if len(subPlans) > 0 {
|
||||
dstDB.Exec("DELETE FROM subscribe") // 先清再插
|
||||
for _, s := range subPlans {
|
||||
dstDB.Create(&s)
|
||||
}
|
||||
}
|
||||
if len(payments) > 0 {
|
||||
dstDB.Exec("DELETE FROM payment")
|
||||
for _, p := range payments {
|
||||
dstDB.Create(&p)
|
||||
}
|
||||
}
|
||||
fmt.Println("OK")
|
||||
|
||||
// ── Step 7: 写入付费用户数据(事务) ──
|
||||
fmt.Print("[7/10] 写入付费用户数据... ")
|
||||
err = dstDB.Transaction(func(tx *gorm.DB) error {
|
||||
tx.Exec("SET FOREIGN_KEY_CHECKS = 0")
|
||||
|
||||
if err := tx.CreateInBatches(&users, batchSize).Error; err != nil {
|
||||
return fmt.Errorf("写入 user 失败: %w", err)
|
||||
}
|
||||
if err := tx.CreateInBatches(&auths, batchSize).Error; err != nil {
|
||||
return fmt.Errorf("写入 auth_methods 失败: %w", err)
|
||||
}
|
||||
if err := tx.CreateInBatches(&devices, batchSize).Error; err != nil {
|
||||
return fmt.Errorf("写入 device 失败: %w", err)
|
||||
}
|
||||
if err := tx.CreateInBatches(&orders, batchSize).Error; err != nil {
|
||||
return fmt.Errorf("写入 order 失败: %w", err)
|
||||
}
|
||||
if err := tx.CreateInBatches(&subscribes, batchSize).Error; err != nil {
|
||||
return fmt.Errorf("写入 subscribe 失败: %w", err)
|
||||
}
|
||||
if err := tx.CreateInBatches(&iaps, batchSize).Error; err != nil {
|
||||
return fmt.Errorf("写入 iap 失败: %w", err)
|
||||
}
|
||||
|
||||
tx.Exec("SET FOREIGN_KEY_CHECKS = 1")
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("写入失败: %v", err)
|
||||
}
|
||||
fmt.Println("OK")
|
||||
|
||||
// ── Step 8: 创建家庭组 ──
|
||||
fmt.Print("[8/10] 创建家庭组... ")
|
||||
now := time.Now()
|
||||
familyCount := 0
|
||||
|
||||
err = dstDB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, uid := range paidIDs {
|
||||
// 只为多设备用户创建家庭组
|
||||
if len(deviceByUser[uid]) <= 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
family := UserFamily{
|
||||
OwnerUserId: uid,
|
||||
MaxMembers: defaultFamilyMaxSize,
|
||||
Status: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&family).Error; err != nil {
|
||||
return fmt.Errorf("创建家庭组(uid=%d)失败: %w", uid, err)
|
||||
}
|
||||
|
||||
member := UserFamilyMember{
|
||||
FamilyId: family.Id,
|
||||
UserId: uid,
|
||||
Role: familyRoleOwner,
|
||||
Status: 1,
|
||||
JoinSource: "migration",
|
||||
JoinedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&member).Error; err != nil {
|
||||
return fmt.Errorf("创建家主成员(uid=%d)失败: %w", uid, err)
|
||||
}
|
||||
familyCount++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("家庭组创建失败: %v", err)
|
||||
}
|
||||
fmt.Printf("%d 个家庭组\n", familyCount)
|
||||
|
||||
// ── Step 9: 多设备拆分 ──
|
||||
fmt.Print("[9/10] 多设备拆分... ")
|
||||
splitCount := 0
|
||||
|
||||
err = dstDB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, s := range splits {
|
||||
// 1. 创建新用户
|
||||
newUser := User{
|
||||
Password: "",
|
||||
Algo: "default",
|
||||
Salt: "default",
|
||||
Enable: boolPtr(true),
|
||||
IsAdmin: boolPtr(false),
|
||||
OnlyFirstPurchase: boolPtr(true),
|
||||
EnableBalanceNotify: boolPtr(false),
|
||||
EnableLoginNotify: boolPtr(false),
|
||||
EnableSubscribeNotify: boolPtr(false),
|
||||
EnableTradeNotify: boolPtr(false),
|
||||
CreatedAt: s.Device.CreatedAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&newUser).Error; err != nil {
|
||||
return fmt.Errorf("创建拆分用户失败(owner=%d, device=%d): %w", s.OwnerUID, s.Device.Id, err)
|
||||
}
|
||||
|
||||
// 2. 转移设备到新用户
|
||||
if err := tx.Model(&Device{}).Where("id = ?", s.Device.Id).
|
||||
Update("user_id", newUser.Id).Error; err != nil {
|
||||
return fmt.Errorf("转移设备失败: %w", err)
|
||||
}
|
||||
|
||||
// 3. 转移 auth_method 到新用户
|
||||
if s.AuthMethod != nil {
|
||||
if err := tx.Model(&AuthMethod{}).Where("id = ?", s.AuthMethod.Id).
|
||||
Update("user_id", newUser.Id).Error; err != nil {
|
||||
return fmt.Errorf("转移 auth_method 失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 查找原用户的家庭组(如果不存在则创建,虽然理论上 Step 8 已经为多设备用户创建了)
|
||||
var family UserFamily
|
||||
if err := tx.Where("owner_user_id = ?", s.OwnerUID).First(&family).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 补救措施:为该用户创建一个家庭组
|
||||
family = UserFamily{
|
||||
OwnerUserId: s.OwnerUID,
|
||||
MaxMembers: defaultFamilyMaxSize,
|
||||
Status: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&family).Error; err != nil {
|
||||
return fmt.Errorf("创建家庭组补救失败(owner=%d): %w", s.OwnerUID, err)
|
||||
}
|
||||
|
||||
// 创建家主成员
|
||||
ownerMember := UserFamilyMember{
|
||||
FamilyId: family.Id,
|
||||
UserId: s.OwnerUID,
|
||||
Role: familyRoleOwner,
|
||||
Status: 1,
|
||||
JoinSource: "migration_split_recovery",
|
||||
JoinedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&ownerMember).Error; err != nil {
|
||||
return fmt.Errorf("创建家主成员补救失败(owner=%d): %w", s.OwnerUID, err)
|
||||
}
|
||||
familyCount++ // 更新计数器
|
||||
} else {
|
||||
return fmt.Errorf("查找家庭组失败(owner=%d): %w", s.OwnerUID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 加入家庭组
|
||||
member := UserFamilyMember{
|
||||
FamilyId: family.Id,
|
||||
UserId: newUser.Id,
|
||||
Role: familyRoleMember,
|
||||
Status: 1,
|
||||
JoinSource: "migration_split",
|
||||
JoinedAt: s.Device.CreatedAt,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(&member).Error; err != nil {
|
||||
return fmt.Errorf("添加家庭成员失败: %w", err)
|
||||
}
|
||||
|
||||
splitCount++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("设备拆分失败: %v", err)
|
||||
}
|
||||
fmt.Printf("%d 个设备\n", splitCount)
|
||||
|
||||
// ── Step 10: 修复各表 AUTO_INCREMENT ──
|
||||
// 确保迁移后新写入的记录不会触发主键冲突
|
||||
fmt.Print("[10/10] 修复 AUTO_INCREMENT... ")
|
||||
type autoIncTable struct {
|
||||
table string // 表名(不含反引号)
|
||||
quoted string // SQL 中使用的表名(含反引号)
|
||||
}
|
||||
autoIncTables := []autoIncTable{
|
||||
{"user", "`user`"},
|
||||
{"user_auth_methods", "`user_auth_methods`"},
|
||||
{"user_device", "`user_device`"},
|
||||
{"order", "`order`"},
|
||||
{"user_subscribe", "`user_subscribe`"},
|
||||
{"apple_iap_transactions", "`apple_iap_transactions`"},
|
||||
{"user_family", "`user_family`"},
|
||||
{"user_family_member", "`user_family_member`"},
|
||||
}
|
||||
for _, t := range autoIncTables {
|
||||
var maxID int64
|
||||
dstDB.Raw(fmt.Sprintf("SELECT COALESCE(MAX(id), 0) FROM %s", t.quoted)).Scan(&maxID)
|
||||
nextID := maxID + 1
|
||||
if err := dstDB.Exec(fmt.Sprintf("ALTER TABLE %s AUTO_INCREMENT = %d", t.quoted, nextID)).Error; err != nil {
|
||||
log.Printf(" 警告: 修复 %s AUTO_INCREMENT 失败: %v", t.table, err)
|
||||
} else {
|
||||
fmt.Printf("\n %-30s MAX(id)=%-8d AUTO_INCREMENT→%d", t.table, maxID, nextID)
|
||||
}
|
||||
}
|
||||
fmt.Println("\nOK")
|
||||
|
||||
// ── 结果 ──
|
||||
fmt.Println()
|
||||
fmt.Println("=== 迁移完成 ===")
|
||||
fmt.Printf(" 用户: %d (原始) + %d (拆分) = %d\n", len(users), splitCount, len(users)+splitCount)
|
||||
fmt.Printf(" 家庭组: %d\n", familyCount)
|
||||
fmt.Printf(" 家庭成员: %d (家主) + %d (拆分) = %d\n", familyCount, splitCount, familyCount+splitCount)
|
||||
fmt.Printf(" 订单: %d\n", len(orders))
|
||||
fmt.Printf(" 订阅: %d\n", len(subscribes))
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
BIN
scripts/output/migrate_paid_users
Executable file
BIN
scripts/output/migrate_paid_users
Executable file
Binary file not shown.
3189
scripts/output/paid_users_migration.sql
Normal file
3189
scripts/output/paid_users_migration.sql
Normal file
File diff suppressed because one or more lines are too long
3189
scripts/output/paid_users_migration_remote.sql
Normal file
3189
scripts/output/paid_users_migration_remote.sql
Normal file
File diff suppressed because one or more lines are too long
33
说明文档.md
Normal file
33
说明文档.md
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user