feat(auth): add test bypass code 202511 for bind_email_with_verification
Build docker and publish / build (20.15.1) (push) Failing after 5m30s
Build docker and publish / build (20.15.1) (push) Failing after 5m30s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,319 +0,0 @@
|
||||
# 付费用户数据迁移报告
|
||||
|
||||
> 生成时间: 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 条 |
|
||||
@@ -1,185 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/forgoer/openssl"
|
||||
)
|
||||
|
||||
// ===== AES 加解密(与 pkg/aes/aes.go 一致)=====
|
||||
|
||||
func generateKey(key string) []byte {
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return hash[:32]
|
||||
}
|
||||
|
||||
func generateIv(iv, key string) []byte {
|
||||
h := md5.New()
|
||||
h.Write([]byte(iv))
|
||||
return generateKey(hex.EncodeToString(h.Sum(nil)) + key)
|
||||
}
|
||||
|
||||
func aesEncrypt(plainText []byte, keyStr string) (string, string, error) {
|
||||
nonce := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
key := generateKey(keyStr)
|
||||
iv := generateIv(nonce, keyStr)
|
||||
dst, err := openssl.AesCBCEncrypt(plainText, key, iv, openssl.PKCS7_PADDING)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(dst), nonce, nil
|
||||
}
|
||||
|
||||
func aesDecrypt(cipherText, keyStr, ivStr string) (string, error) {
|
||||
decode, err := base64.StdEncoding.DecodeString(cipherText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := generateKey(keyStr)
|
||||
iv := generateIv(ivStr, keyStr)
|
||||
dst, err := openssl.AesCBCDecrypt(decode, key, iv, openssl.PKCS7_PADDING)
|
||||
return string(dst), err
|
||||
}
|
||||
|
||||
// ===== 主逻辑 =====
|
||||
|
||||
func main() {
|
||||
deviceID := flag.String("id", "", "设备 ID (identifier)")
|
||||
secret := flag.String("secret", "", "security_secret (device.security_secret)")
|
||||
host := flag.String("host", "https://api.hifast.biz", "API 地址")
|
||||
flag.Parse()
|
||||
|
||||
if *deviceID == "" || *secret == "" {
|
||||
fmt.Println("用法: go run scripts/debug_device_login.go -id <设备ID> -secret <security_secret>")
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 构造登录请求体
|
||||
loginBody := map[string]interface{}{
|
||||
"identifier": *deviceID,
|
||||
"user_agent": "DebugScript/1.0",
|
||||
}
|
||||
loginJSON, _ := json.Marshal(loginBody)
|
||||
|
||||
// 2. AES 加密请求体
|
||||
encData, nonce, err := aesEncrypt(loginJSON, *secret)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 加密失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
encBody := map[string]interface{}{
|
||||
"data": encData,
|
||||
"time": nonce,
|
||||
}
|
||||
encBodyJSON, _ := json.Marshal(encBody)
|
||||
|
||||
fmt.Printf("📤 登录请求体(加密): %s\n\n", encBodyJSON)
|
||||
|
||||
// 3. 发起设备登录请求
|
||||
loginURL := *host + "/v1/auth/login/device"
|
||||
req, _ := http.NewRequest("POST", loginURL, bytes.NewReader(encBodyJSON))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Login-Type", "device")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 登录请求失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("📥 登录响应(原始): %s\n\n", respBody)
|
||||
|
||||
// 4. 解密响应
|
||||
var respMap map[string]interface{}
|
||||
if err := json.Unmarshal(respBody, &respMap); err != nil {
|
||||
fmt.Printf("❌ 解析响应 JSON 失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
var token string
|
||||
if dataField, ok := respMap["data"]; ok {
|
||||
switch d := dataField.(type) {
|
||||
case map[string]interface{}:
|
||||
// 加密响应
|
||||
encResp, _ := d["data"].(string)
|
||||
ivResp, _ := d["time"].(string)
|
||||
if encResp != "" && ivResp != "" {
|
||||
decrypted, err := aesDecrypt(encResp, *secret, ivResp)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 解密响应失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("📥 登录响应(解密): %s\n\n", decrypted)
|
||||
var loginData map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(decrypted), &loginData); err == nil {
|
||||
token, _ = loginData["token"].(string)
|
||||
}
|
||||
}
|
||||
case string:
|
||||
// 未加密直接是 token 字符串
|
||||
token = d
|
||||
}
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
fmt.Println("❌ 未获取到 token,登录失败")
|
||||
return
|
||||
}
|
||||
fmt.Printf("✅ Token: %s\n\n", token)
|
||||
|
||||
// 5. 查询订阅
|
||||
subURL := *host + "/v1/public/user/subscribe"
|
||||
subReq, _ := http.NewRequest("GET", subURL, nil)
|
||||
subReq.Header.Set("Authorization", "Bearer "+token)
|
||||
subReq.Header.Set("Login-Type", "device")
|
||||
subReq.Header.Set("X-App-Id", "debug")
|
||||
|
||||
subResp, err := client.Do(subReq)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 查询订阅失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer subResp.Body.Close()
|
||||
|
||||
subBody, _ := io.ReadAll(subResp.Body)
|
||||
fmt.Printf("📥 订阅响应(原始): %s\n\n", subBody)
|
||||
|
||||
// 6. 解密订阅响应
|
||||
var subRespMap map[string]interface{}
|
||||
if err := json.Unmarshal(subBody, &subRespMap); err == nil {
|
||||
if dataField, ok := subRespMap["data"]; ok {
|
||||
if d, ok := dataField.(map[string]interface{}); ok {
|
||||
encResp, _ := d["data"].(string)
|
||||
ivResp, _ := d["time"].(string)
|
||||
if encResp != "" && ivResp != "" {
|
||||
decrypted, err := aesDecrypt(encResp, *secret, ivResp)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 解密订阅响应失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
// 格式化输出
|
||||
var pretty interface{}
|
||||
json.Unmarshal([]byte(decrypted), &pretty)
|
||||
out, _ := json.MarshalIndent(pretty, "", " ")
|
||||
fmt.Printf("📋 订阅信息(解密):\n%s\n", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := flag.String("dsn", os.Getenv("PPANEL_MYSQL_DSN"), "MySQL DSN; defaults to PPANEL_MYSQL_DSN")
|
||||
flag.Parse()
|
||||
if strings.TrimSpace(*dsn) == "" {
|
||||
log.Fatal("missing DSN: pass -dsn or set PPANEL_MYSQL_DSN")
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", *dsn)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err = db.Ping(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
mustPrintRows(db, "db/info", `
|
||||
SELECT NOW() AS db_now,
|
||||
(SELECT COUNT(*) FROM user) AS users,
|
||||
(SELECT COUNT(*) FROM user_subscribe) AS user_subscribes,
|
||||
(SELECT COUNT(*) FROM `+"`order`"+`) AS orders`)
|
||||
|
||||
mustPrintRows(db, "bug1/confusable-email-trials", `
|
||||
SELECT uam.user_id,
|
||||
uam.auth_identifier,
|
||||
us.id AS user_subscribe_id,
|
||||
us.order_id,
|
||||
us.status,
|
||||
us.expire_time,
|
||||
us.created_at
|
||||
FROM user_auth_methods uam
|
||||
JOIN user_subscribe us ON us.user_id = uam.user_id
|
||||
WHERE uam.auth_type = 'email'
|
||||
AND us.order_id = 0
|
||||
AND (
|
||||
uam.auth_identifier LIKE '%@gmaial.com'
|
||||
OR uam.auth_identifier LIKE '%@gmial.com'
|
||||
OR uam.auth_identifier LIKE '%@gamil.com'
|
||||
OR uam.auth_identifier LIKE '%+%@%'
|
||||
OR uam.auth_identifier REGEXP '^[^@]*\\.[^@]*@gmail\\.com$'
|
||||
)
|
||||
ORDER BY us.created_at DESC
|
||||
LIMIT 50`)
|
||||
|
||||
mustPrintRows(db, "bug2-visible-duplicate-subscriptions", `
|
||||
SELECT scoped.owner_user_id,
|
||||
COUNT(*) AS visible_subscribe_count,
|
||||
GROUP_CONCAT(scoped.user_subscribe_id ORDER BY scoped.expire_time DESC) AS user_subscribe_ids,
|
||||
GROUP_CONCAT(scoped.subscribe_id ORDER BY scoped.expire_time DESC) AS subscribe_ids,
|
||||
MAX(scoped.expire_time) AS max_expire_time
|
||||
FROM (
|
||||
SELECT us.id AS user_subscribe_id,
|
||||
us.user_id,
|
||||
COALESCE(uf.owner_user_id, us.user_id) AS owner_user_id,
|
||||
us.subscribe_id,
|
||||
us.status,
|
||||
us.expire_time,
|
||||
us.finished_at
|
||||
FROM user_subscribe us
|
||||
LEFT JOIN user_family_member ufm
|
||||
ON ufm.user_id = us.user_id AND ufm.deleted_at IS NULL AND ufm.status = 1
|
||||
LEFT JOIN user_family uf
|
||||
ON uf.id = ufm.family_id AND uf.deleted_at IS NULL AND uf.status = 1
|
||||
WHERE us.token <> ''
|
||||
AND us.status IN (0,1,2,3,4)
|
||||
AND (us.expire_time > NOW()
|
||||
OR us.finished_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||
OR us.expire_time = FROM_UNIXTIME(0))
|
||||
) scoped
|
||||
GROUP BY scoped.owner_user_id
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY visible_subscribe_count DESC, owner_user_id
|
||||
LIMIT 50`)
|
||||
|
||||
mustPrintRows(db, "bug2-order-subscription-owner-mismatch", `
|
||||
SELECT us.id AS user_subscribe_id,
|
||||
us.user_id AS subscribe_user_id,
|
||||
o.id AS order_id,
|
||||
o.order_no,
|
||||
o.user_id AS order_user_id,
|
||||
o.subscription_user_id,
|
||||
us.status,
|
||||
us.expire_time,
|
||||
us.created_at AS subscribe_created_at,
|
||||
o.created_at AS order_created_at
|
||||
FROM user_subscribe us
|
||||
JOIN `+"`order`"+` o ON o.id = us.order_id
|
||||
WHERE us.user_id <> o.subscription_user_id
|
||||
AND us.token <> ''
|
||||
AND us.status IN (0,1,2,3,4)
|
||||
ORDER BY us.updated_at DESC
|
||||
LIMIT 50`)
|
||||
|
||||
mustPrintRows(db, "bug3-invite-first-orders-missing-gift-days", `
|
||||
SELECT first_orders.user_id AS referee_id,
|
||||
referee.referer_id,
|
||||
first_orders.id AS order_id,
|
||||
first_orders.order_no,
|
||||
first_orders.amount,
|
||||
first_orders.created_at,
|
||||
referer.referral_percentage AS referer_referral_percentage,
|
||||
(SELECT COUNT(*) FROM system_logs sl
|
||||
WHERE sl.type = 34
|
||||
AND sl.object_id = first_orders.user_id
|
||||
AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')) AS referee_gift_logs,
|
||||
(SELECT COUNT(*) FROM system_logs sl
|
||||
WHERE sl.type = 34
|
||||
AND sl.object_id = referee.referer_id
|
||||
AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')) AS referer_gift_logs
|
||||
FROM (
|
||||
SELECT o.*
|
||||
FROM `+"`order`"+` o
|
||||
JOIN (
|
||||
SELECT user_id, MIN(id) AS first_order_id
|
||||
FROM `+"`order`"+`
|
||||
WHERE type IN (1,2)
|
||||
AND status IN (2,5)
|
||||
AND amount > 0
|
||||
GROUP BY user_id
|
||||
) fo ON fo.first_order_id = o.id
|
||||
) first_orders
|
||||
JOIN user referee ON referee.id = first_orders.user_id AND referee.referer_id <> 0
|
||||
JOIN user referer ON referer.id = referee.referer_id
|
||||
WHERE (
|
||||
referer.referral_percentage = 0
|
||||
AND (
|
||||
(SELECT COUNT(*) FROM system_logs sl
|
||||
WHERE sl.type = 34 AND sl.object_id = first_orders.user_id AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')) = 0
|
||||
OR
|
||||
(SELECT COUNT(*) FROM system_logs sl
|
||||
WHERE sl.type = 34 AND sl.object_id = referee.referer_id AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')) = 0
|
||||
)
|
||||
)
|
||||
OR (
|
||||
referer.referral_percentage > 0
|
||||
AND (SELECT COUNT(*) FROM system_logs sl
|
||||
WHERE sl.type = 34 AND sl.object_id = first_orders.user_id AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')) = 0
|
||||
)
|
||||
ORDER BY first_orders.created_at DESC
|
||||
LIMIT 50`)
|
||||
}
|
||||
|
||||
func mustPrintRows(db *sql.DB, title string, query string) {
|
||||
fmt.Printf("\n== %s ==\n", title)
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
log.Fatalf("%s: %v", title, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, err := rows.Columns()
|
||||
if err != nil {
|
||||
log.Fatalf("%s columns: %v", title, err)
|
||||
}
|
||||
fmt.Println(strings.Join(cols, "\t"))
|
||||
|
||||
values := make([]sql.NullString, len(cols))
|
||||
args := make([]any, len(cols))
|
||||
for i := range values {
|
||||
args[i] = &values[i]
|
||||
}
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
if err := rows.Scan(args...); err != nil {
|
||||
log.Fatalf("%s scan: %v", title, err)
|
||||
}
|
||||
out := make([]string, len(cols))
|
||||
for i, value := range values {
|
||||
if value.Valid {
|
||||
out[i] = value.String
|
||||
} else {
|
||||
out[i] = "NULL"
|
||||
}
|
||||
}
|
||||
fmt.Println(strings.Join(out, "\t"))
|
||||
count++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Fatalf("%s rows: %v", title, err)
|
||||
}
|
||||
if count == 0 {
|
||||
fmt.Println("(none)")
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
-- ============================================================
|
||||
-- 修复脚本:为有多设备但无家庭组的用户补建家庭组
|
||||
-- 影响用户数: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
|
||||
@@ -1,283 +0,0 @@
|
||||
-- ============================================================
|
||||
-- 修复脚本:将同一 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;
|
||||
@@ -1,274 +0,0 @@
|
||||
"""Generate ppanel-server test case Excel file."""
|
||||
import os
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import (
|
||||
Font, PatternFill, Alignment, Border, Side
|
||||
)
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
OUTPUT_PATH = os.path.join(os.path.dirname(__file__), "..", "tests", "ppanel_test_cases.xlsx")
|
||||
|
||||
# ── Color palette ──────────────────────────────────────────────────────────────
|
||||
C_HEADER_BG = "1F4E79" # dark blue – header row
|
||||
C_HEADER_FONT = "FFFFFF" # white
|
||||
C_SHEET_TITLE = "2E75B6" # mid blue – sheet title row
|
||||
C_P0_BG = "FFE2E2" # light red – P0
|
||||
C_P1_BG = "FFF2CC" # light yellow – P1
|
||||
C_P2_BG = "E2EFDA" # light green – P2
|
||||
C_BORDER = "BFBFBF"
|
||||
|
||||
def thin_border():
|
||||
s = Side(style="thin", color=C_BORDER)
|
||||
return Border(left=s, right=s, top=s, bottom=s)
|
||||
|
||||
def header_fill(hex_color):
|
||||
return PatternFill("solid", fgColor=hex_color)
|
||||
|
||||
def row_fill(hex_color):
|
||||
return PatternFill("solid", fgColor=hex_color)
|
||||
|
||||
# ── Column definitions ─────────────────────────────────────────────────────────
|
||||
COLUMNS = ["用例ID", "模块", "功能点", "前置条件", "测试步骤", "预期结果",
|
||||
"实际结果", "测试状态", "优先级", "备注"]
|
||||
COL_WIDTHS = [16, 16, 28, 32, 40, 40, 20, 12, 8, 20]
|
||||
|
||||
# ── Test data ──────────────────────────────────────────────────────────────────
|
||||
SHEET1_ORDER = {
|
||||
"name": "订单核心流程",
|
||||
"rows": [
|
||||
# id, 模块, 功能点, 前置, 步骤, 预期, 优先
|
||||
("TC-ORDER-001","订单/预创建","正常预览订单价格","用户已登录,套餐存在且在售","传入有效 subscribe_id, quantity=1","返回 price/amount/discount 字段正确","P0"),
|
||||
("TC-ORDER-002","订单/预创建","数量为0时自动修正为1","用户已登录","quantity=0","自动设为1,正常返回价格","P1"),
|
||||
("TC-ORDER-003","订单/预创建","套餐购买数量限制(Quota)","用户已达到该套餐购买上限","再次预创建同套餐订单","返回 SubscribeQuotaLimit 错误","P0"),
|
||||
("TC-ORDER-004","订单/预创建","新用户专属折扣(24h内注册)","用户注册在24h内,套餐有 new_user_only 折扣","预创建订单","折扣生效,amount < price","P0"),
|
||||
("TC-ORDER-005","订单/预创建","老用户不享受新用户折扣","用户注册超过24h","预创建有 new_user_only 折扣的套餐","返回 SubscribeNewUserOnly 错误","P0"),
|
||||
("TC-ORDER-006","订单/预创建","新用户已购过不重复享受新用户折扣","用户24h内注册,但已购买过该套餐","预创建同套餐","折扣不生效,按原价计算","P0"),
|
||||
("TC-ORDER-007","订单/预创建","优惠券不存在","用户已登录","传入不存在的 coupon code","返回 CouponNotExist 错误","P1"),
|
||||
("TC-ORDER-008","订单/预创建","优惠券已用完(count限制)","优惠券 used_count >= count","传入该优惠券","返回 CouponAlreadyUsed 错误","P1"),
|
||||
("TC-ORDER-009","订单/预创建","优惠券个人使用次数超限","用户已使用该优惠券达 user_limit 次","再次使用","返回 CouponInsufficientUsage 错误","P1"),
|
||||
("TC-ORDER-010","订单/预创建","优惠券不适用于该套餐","优惠券绑定了特定套餐,与当前套餐不符","传入该优惠券","返回 CouponNotApplicable 错误","P1"),
|
||||
("TC-ORDER-011","订单/预创建","支付手续费计算","支付方式有手续费配置","传入 payment_id","feeAmount 正确,amount = 原金额 + 手续费","P1"),
|
||||
("TC-ORDER-012","订单/预创建","礼品金额抵扣","用户 gift_amount > 0","预创建订单","deduction_amount 正确,amount 减去礼品金额","P1"),
|
||||
("TC-ORDER-013","订单/预创建","礼品金额全额抵扣(amount归零)","用户 gift_amount >= 订单金额","预创建订单","amount=0,deduction_amount = 原订单金额","P1"),
|
||||
("TC-ORDER-014","订单/购买","正常购买订阅","用户已登录,套餐在售有库存","发起购买请求","订单创建成功,返回 order_no","P0"),
|
||||
("TC-ORDER-015","订单/购买","套餐库存为0不允许购买","套餐 inventory=0","发起购买","返回 SubscribeOutOfStock 错误","P0"),
|
||||
("TC-ORDER-016","订单/购买","单订阅模式:已有 pending 订单自动关闭","SingleModel=true,用户已有 pending 订单","对同套餐再次购买","旧 pending 订单关闭,新订单创建成功","P0"),
|
||||
("TC-ORDER-017","订单/购买","单订阅模式:自动路由为续费","SingleModel=true,用户已有有效订阅","购买相同套餐","订单类型=2(续费),parent_id 指向原订单","P0"),
|
||||
("TC-ORDER-018","订单/购买","数量超过 MaxQuantity 限制","—","quantity > MaxQuantity","返回 InvalidParams 错误","P1"),
|
||||
("TC-ORDER-019","订单/购买","金额超过 MaxOrderAmount","套餐单价极高","购买","返回 InvalidParams 错误","P1"),
|
||||
("TC-ORDER-020","订单/购买","15分钟后自动关闭未支付订单","订单已创建,未支付","等待15分钟后触发队列","订单状态变为 Close(3)","P0"),
|
||||
("TC-ORDER-021","订单/激活","订单激活(NewPurchase)","订单状态=已支付(2),类型=1","触发激活队列","用户订阅创建,订单状态变为 Finished(5)","P0"),
|
||||
("TC-ORDER-022","订单/激活","订单激活(Renewal)","订单状态=已支付,类型=2","触发激活","订阅到期时间延长","P0"),
|
||||
("TC-ORDER-023","订单/激活","订单激活(ResetTraffic)","订单状态=已支付,类型=3","触发激活","用户流量重置","P0"),
|
||||
("TC-ORDER-024","订单/激活","订单激活(Recharge)","订单状态=已支付,类型=4","触发激活","用户余额增加","P0"),
|
||||
("TC-ORDER-025","订单/激活","订单激活(Redemption)","订单状态=已支付,类型=5","触发激活","兑换码激活成功","P0"),
|
||||
("TC-ORDER-026","订单/激活","幂等性:已完成订单不重复处理","订单状态=Finished(5)","再次触发激活","直接跳过,不重复执行","P0"),
|
||||
("TC-ORDER-027","订单/激活","非已支付状态订单不处理","订单状态=Pending(1) 或 Close(3)","触发激活","跳过,返回 ErrInvalidOrderStatus","P0"),
|
||||
]
|
||||
}
|
||||
|
||||
SHEET2_USER = {
|
||||
"name": "用户模块",
|
||||
"rows": [
|
||||
("TC-USER-001","用户/注册","邮箱注册","邮箱未注册","提交有效邮箱+密码","用户创建成功,返回 token","P0"),
|
||||
("TC-USER-002","用户/登录","邮箱密码登录","用户已注册","提交正确邮箱+密码","返回 JWT token,session 写入 Redis","P0"),
|
||||
("TC-USER-003","用户/设备登录","AES-CBC 加密设备登录","配置 security_secret","Body 使用正确密钥加密","登录成功","P0"),
|
||||
("TC-USER-004","用户/设备登录","错误密钥设备登录","—","Body 使用错误密钥加密","返回认证失败错误","P0"),
|
||||
("TC-USER-005","用户/退出登录","解绑设备(退出家庭组)","用户在家庭组中","调用 unbind_device","用户从家庭组移除,device 记录不删除、不禁用","P0"),
|
||||
("TC-USER-006","用户/注销账号","正常注销","用户已登录","调用 delete_account","账号软删除,auth_methods 软删除,Redis 缓存清理","P0"),
|
||||
("TC-USER-007","用户/注销账号","家主注销 → 解散家庭","用户是家庭组家主","注销账号","家庭所有成员 status=removed,family status=disabled","P0"),
|
||||
("TC-USER-008","用户/注销账号","成员注销 → 仅退出家庭","用户是家庭组成员","注销账号","仅该成员退出,家庭组继续存在","P0"),
|
||||
("TC-USER-009","用户/注销","缓存清理(email key 残留问题)","用户已注销,email 缓存可能残留","注销后检查 Redis","cache:user:email:{email} 已删除","P0"),
|
||||
("TC-USER-010","用户/邀请","绑定邀请码","用户未绑定过邀请码","提交有效邀请码","referer_id 写入,邀请关系建立","P1"),
|
||||
("TC-USER-011","用户/邀请","重复绑定邀请码","用户已绑定邀请码","再次绑定","返回错误,不允许重复绑定","P1"),
|
||||
("TC-USER-012","用户/佣金","首购返佣","用户通过邀请码注册,完成首次付款","订单激活","邀请人佣金增加","P1"),
|
||||
("TC-USER-013","用户/佣金","only_first_purchase=true 仅首购返佣","配置仅首购","被邀请人第二次购买","不再发佣金","P1"),
|
||||
("TC-USER-014","用户/佣金","赠送天数(双方)","邀请关系建立,被邀请人购买","订单激活","邀请人和被邀请人各获得赠送天数","P1"),
|
||||
("TC-USER-015","用户/家庭组","踢出家庭成员","用户是家庭组家主","踢出某成员","该成员退出家庭组,设备记录不变","P1"),
|
||||
("TC-USER-016","用户/订阅","查看订阅状态(含节点分组名和限速时间)","用户有有效订阅","查询订阅状态","返回节点分组名、限速起止时间","P1"),
|
||||
]
|
||||
}
|
||||
|
||||
SHEET3_SUB = {
|
||||
"name": "订阅套餐",
|
||||
"rows": [
|
||||
("TC-SUB-001","套餐/列表","获取可用套餐列表","—","调用套餐列表接口","返回所有在售套餐","P1"),
|
||||
("TC-SUB-002","套餐/列表","老版本客户端裁剪套餐列表","请求头含 X-App-Id(老版本标识)","调用套餐列表","每个套餐的 discount 列表去掉最后一项","P1"),
|
||||
("TC-SUB-003","套餐/购买限制","Quota 限制(每用户购买上限)","套餐设置 quota=1","用户购买2次同套餐","第二次返回 SubscribeQuotaLimit","P0"),
|
||||
("TC-SUB-004","套餐/折扣","数量折扣梯度","套餐配置多级数量折扣","购买不同数量","对应折扣率正确应用","P1"),
|
||||
("TC-SUB-005","套餐/库存","库存充足时正常购买","inventory > 0","购买","成功,inventory -1","P1"),
|
||||
("TC-SUB-006","套餐/库存","库存=-1(无限库存)","inventory=-1","多次购买","不减少库存,始终可购","P1"),
|
||||
]
|
||||
}
|
||||
|
||||
SHEET4_PAY = {
|
||||
"name": "支付与优惠券",
|
||||
"rows": [
|
||||
("TC-PAY-001","支付/方式","获取可用支付方式列表","—","调用支付方式接口","返回当前配置的支付方式","P1"),
|
||||
("TC-PAY-002","支付/手续费","固定手续费计算","支付方式配置固定手续费","下单","feeAmount = 配置值","P1"),
|
||||
("TC-PAY-003","支付/手续费","百分比手续费计算","支付方式配置百分比手续费","下单","feeAmount = amount × 百分比","P1"),
|
||||
("TC-PAY-004","支付/手续费","amount=0 时不计算手续费","礼品金额全额抵扣后 amount=0","下单","feeAmount=0","P1"),
|
||||
("TC-CPN-001","优惠券/固定减免","固定金额优惠券","优惠券类型=固定,value=100","使用优惠券","订单减免100","P1"),
|
||||
("TC-CPN-002","优惠券/百分比","百分比优惠券","优惠券类型=百分比,value=0.8","使用优惠券","订单金额×0.8","P1"),
|
||||
("TC-CPN-003","优惠券/过期","过期优惠券不可用","优惠券 expire_at < now","使用","返回错误(CouponExpired)","P1"),
|
||||
("TC-CPN-004","优惠券/套餐绑定","仅限指定套餐使用","优惠券绑定套餐A","用于套餐B","返回 CouponNotApplicable","P1"),
|
||||
]
|
||||
}
|
||||
|
||||
SHEET5_IAP = {
|
||||
"name": "IAP苹果内购",
|
||||
"rows": [
|
||||
("TC-IAP-001","IAP/绑定","绑定苹果内购 transaction","苹果 transaction 有效","提交 transaction_id","订单创建并激活,订阅开通","P1"),
|
||||
("TC-IAP-002","IAP/绑定","重复绑定同一 transaction","transaction 已绑定","再次提交","幂等处理,不重复创建订单","P1"),
|
||||
("TC-IAP-003","IAP/单订阅模式","内购续费路由","SingleModel=true,用户已有订阅","提交续费 transaction","路由为续费类型订单","P1"),
|
||||
("TC-IAP-004","IAP/对账","日对账任务","配置了 IAP 对账","触发日对账","检查并补处理漏掉的 transaction","P2"),
|
||||
]
|
||||
}
|
||||
|
||||
SHEET6_LOG = {
|
||||
"name": "日志与缓存",
|
||||
"rows": [
|
||||
("TC-LOG-001","日志/佣金","佣金记录写入 system_logs","发生佣金发放","触发订单激活","type=33 的记录写入,content.type 为 331 或 332","P2"),
|
||||
("TC-LOG-002","日志/礼品金额","礼品金额扣除记录","用户有 gift_amount,下单扣除","购买","GiftTypeReduce 记录写入 system_logs","P2"),
|
||||
("TC-CACHE-001","缓存/用户","注销后 user email 缓存清理","用户已注销","检查 Redis","cache:user:email:{email} 已删除","P0"),
|
||||
("TC-CACHE-002","缓存/订阅","订阅 token 缓存有效","用户有订阅","查询订阅","从 cache:user:subscribe:token:{token} 命中","P2"),
|
||||
("TC-CACHE-003","缓存/签名","X-App-Id 签名验证","AppSecrets 已配置","发送带签名请求","验签通过,正常处理","P1"),
|
||||
("TC-CACHE-004","缓存/签名","无 X-App-Id 跳过签名","—","发送无签名请求","直接通过,不验签","P1"),
|
||||
]
|
||||
}
|
||||
|
||||
SHEET7_QUEUE = {
|
||||
"name": "队列任务",
|
||||
"rows": [
|
||||
("TC-QUEUE-001","队列/订单关闭","超时自动关闭订单","未支付订单存在","等待15分钟","订单状态=Close","P0"),
|
||||
("TC-QUEUE-002","队列/订阅检查","定期检查订阅到期","用户订阅即将到期","触发 checkSubscription","到期通知发送","P2"),
|
||||
("TC-QUEUE-003","队列/流量统计","服务器流量统计写入","有流量数据上报","触发 trafficStat","流量数据正确写入 DB","P2"),
|
||||
("TC-QUEUE-004","队列/邮件","批量发送邮件任务","已创建批量邮件任务","触发队列","邮件发送成功,任务状态更新","P2"),
|
||||
("TC-QUEUE-005","队列/流量重置","定期重置用户流量","配置了流量重置周期","触发 resetTraffic","用户流量归零","P2"),
|
||||
]
|
||||
}
|
||||
|
||||
ALL_SHEETS = [SHEET1_ORDER, SHEET2_USER, SHEET3_SUB, SHEET4_PAY, SHEET5_IAP, SHEET6_LOG, SHEET7_QUEUE]
|
||||
|
||||
PRIORITY_FILL = {
|
||||
"P0": row_fill(C_P0_BG),
|
||||
"P1": row_fill(C_P1_BG),
|
||||
"P2": row_fill(C_P2_BG),
|
||||
}
|
||||
|
||||
|
||||
def write_sheet(wb: Workbook, sheet_def: dict):
|
||||
ws = wb.create_sheet(title=sheet_def["name"])
|
||||
rows = sheet_def["rows"]
|
||||
|
||||
# ── Title row ──────────────────────────────────────────────────────────────
|
||||
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(COLUMNS))
|
||||
title_cell = ws.cell(row=1, column=1, value=f"ppanel-server 测试用例 — {sheet_def['name']}")
|
||||
title_cell.font = Font(name="微软雅黑", bold=True, size=13, color=C_HEADER_FONT)
|
||||
title_cell.fill = header_fill(C_SHEET_TITLE)
|
||||
title_cell.alignment = Alignment(horizontal="center", vertical="center")
|
||||
ws.row_dimensions[1].height = 28
|
||||
|
||||
# ── Header row ─────────────────────────────────────────────────────────────
|
||||
for col_idx, col_name in enumerate(COLUMNS, start=1):
|
||||
cell = ws.cell(row=2, column=col_idx, value=col_name)
|
||||
cell.font = Font(name="微软雅黑", bold=True, size=10, color=C_HEADER_FONT)
|
||||
cell.fill = header_fill(C_HEADER_BG)
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
cell.border = thin_border()
|
||||
ws.row_dimensions[2].height = 22
|
||||
|
||||
# ── Data rows ──────────────────────────────────────────────────────────────
|
||||
for r_idx, row in enumerate(rows, start=3):
|
||||
tc_id, module, feature, precond, steps, expected, priority = row
|
||||
values = [tc_id, module, feature, precond, steps, expected, "", "", priority, ""]
|
||||
fill = PRIORITY_FILL.get(priority, None)
|
||||
for c_idx, val in enumerate(values, start=1):
|
||||
cell = ws.cell(row=r_idx, column=c_idx, value=val)
|
||||
cell.font = Font(name="微软雅黑", size=9)
|
||||
cell.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
|
||||
cell.border = thin_border()
|
||||
if fill:
|
||||
cell.fill = fill
|
||||
ws.row_dimensions[r_idx].height = 45
|
||||
|
||||
# ── Column widths ──────────────────────────────────────────────────────────
|
||||
for col_idx, width in enumerate(COL_WIDTHS, start=1):
|
||||
ws.column_dimensions[get_column_letter(col_idx)].width = width
|
||||
|
||||
# ── Freeze panes ──────────────────────────────────────────────────────────
|
||||
ws.freeze_panes = "A3"
|
||||
|
||||
# ── Auto filter ───────────────────────────────────────────────────────────
|
||||
ws.auto_filter.ref = f"A2:{get_column_letter(len(COLUMNS))}2"
|
||||
|
||||
|
||||
def write_legend_sheet(wb: Workbook):
|
||||
ws = wb.create_sheet(title="说明", index=0)
|
||||
ws.column_dimensions["A"].width = 18
|
||||
ws.column_dimensions["B"].width = 50
|
||||
|
||||
title = ws.cell(row=1, column=1, value="ppanel-server 测试用例说明")
|
||||
ws.merge_cells("A1:B1")
|
||||
title.font = Font(name="微软雅黑", bold=True, size=13, color=C_HEADER_FONT)
|
||||
title.fill = header_fill(C_SHEET_TITLE)
|
||||
title.alignment = Alignment(horizontal="center", vertical="center")
|
||||
ws.row_dimensions[1].height = 28
|
||||
|
||||
legend_data = [
|
||||
("项目", "说明"),
|
||||
("测试框架", "ppanel-server — go-zero + Gin"),
|
||||
("数据库", "本地 MySQL(真实),禁止 SQLite"),
|
||||
("Redis", "本地 Redis 或 miniredis"),
|
||||
("时间戳规范", "后端统一返回秒级 Unix(),前端 ×1000"),
|
||||
("", ""),
|
||||
("优先级", "含义"),
|
||||
("P0(红色)", "核心业务,必须通过。订单/认证/缓存清理等"),
|
||||
("P1(黄色)", "重要功能,强烈建议测试。折扣/优惠券/邀请等"),
|
||||
("P2(绿色)", "辅助功能,建议测试。日志/队列/IAP 等"),
|
||||
("", ""),
|
||||
("测试状态", "填写规范"),
|
||||
("Pass", "用例通过"),
|
||||
("Fail", "用例失败,需记录实际结果"),
|
||||
("Block", "用例被阻塞(依赖功能未就绪)"),
|
||||
("Skip", "本轮跳过"),
|
||||
("", ""),
|
||||
("Sheet 说明", ""),
|
||||
("Sheet1 订单核心流程", "27 条:预创建/购买/激活全流程"),
|
||||
("Sheet2 用户模块", "16 条:注册/登录/注销/邀请/家庭组"),
|
||||
("Sheet3 订阅套餐", "6 条:库存/折扣/限额"),
|
||||
("Sheet4 支付与优惠券", "8 条:手续费/优惠券各类型"),
|
||||
("Sheet5 IAP苹果内购", "4 条:内购/对账"),
|
||||
("Sheet6 日志与缓存", "6 条:日志写入/缓存清理"),
|
||||
("Sheet7 队列任务", "5 条:队列任务验证"),
|
||||
]
|
||||
|
||||
for r_idx, (key, val) in enumerate(legend_data, start=2):
|
||||
c1 = ws.cell(row=r_idx, column=1, value=key)
|
||||
c2 = ws.cell(row=r_idx, column=2, value=val)
|
||||
for c in (c1, c2):
|
||||
c.font = Font(name="微软雅黑", size=9)
|
||||
c.alignment = Alignment(vertical="center", wrap_text=True)
|
||||
c.border = thin_border()
|
||||
if key in ("项目", "优先级", "测试状态", "Sheet 说明"):
|
||||
for c in (c1, c2):
|
||||
c.font = Font(name="微软雅黑", bold=True, size=9, color=C_HEADER_FONT)
|
||||
c.fill = header_fill(C_HEADER_BG)
|
||||
ws.row_dimensions[r_idx].height = 18
|
||||
|
||||
|
||||
def main():
|
||||
os.makedirs(os.path.dirname(os.path.abspath(OUTPUT_PATH)), exist_ok=True)
|
||||
wb = Workbook()
|
||||
wb.remove(wb.active) # remove default sheet
|
||||
|
||||
write_legend_sheet(wb)
|
||||
for sheet_def in ALL_SHEETS:
|
||||
write_sheet(wb, sheet_def)
|
||||
|
||||
wb.save(OUTPUT_PATH)
|
||||
print(f"Excel saved: {os.path.abspath(OUTPUT_PATH)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,204 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
type duplicateGroup struct {
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type subscriptionRow struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
OrderID int64 `json:"order_id"`
|
||||
SubscribeID int64 `json:"subscribe_id"`
|
||||
ExpireTime time.Time `json:"expire_time"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
Upload int64 `json:"upload"`
|
||||
ExpiredDownload int64 `json:"expired_download"`
|
||||
ExpiredUpload int64 `json:"expired_upload"`
|
||||
Status uint8 `json:"status"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type mergePlan struct {
|
||||
OwnerUserID int64 `json:"owner_user_id"`
|
||||
Keep subscriptionRow `json:"keep"`
|
||||
Merge []subscriptionRow `json:"merge"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := flag.String("dsn", os.Getenv("PPANEL_MYSQL_DSN"), "MySQL DSN; defaults to PPANEL_MYSQL_DSN")
|
||||
execute := flag.Bool("execute", false, "apply changes; default is dry-run")
|
||||
flag.Parse()
|
||||
|
||||
if strings.TrimSpace(*dsn) == "" {
|
||||
log.Fatal("missing DSN: pass -dsn or set PPANEL_MYSQL_DSN")
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", *dsn)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
groups, err := findDuplicateGroups(db)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
plans := make([]mergePlan, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
plan, err := buildPlan(db, group.OwnerUserID)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if len(plan.Merge) > 0 {
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(plans); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if !*execute {
|
||||
fmt.Fprintf(os.Stderr, "dry-run only: %d duplicate owner groups found\n", len(plans))
|
||||
return
|
||||
}
|
||||
|
||||
for _, plan := range plans {
|
||||
if err := applyPlan(db, plan); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "merged %d duplicate owner groups\n", len(plans))
|
||||
}
|
||||
|
||||
func findDuplicateGroups(db *sql.DB) ([]duplicateGroup, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT owner_user_id, COUNT(1) AS cnt
|
||||
FROM (
|
||||
SELECT us.id,
|
||||
COALESCE(uf.owner_user_id, us.user_id) AS owner_user_id
|
||||
FROM user_subscribe us
|
||||
LEFT JOIN user_family_member ufm
|
||||
ON ufm.user_id = us.user_id AND ufm.deleted_at IS NULL AND ufm.status = 1
|
||||
LEFT JOIN user_family uf
|
||||
ON uf.id = ufm.family_id AND uf.deleted_at IS NULL AND uf.status = 1
|
||||
WHERE us.token <> ''
|
||||
AND us.status IN (0, 1, 2, 3, 4)
|
||||
) scoped
|
||||
GROUP BY owner_user_id
|
||||
HAVING COUNT(1) > 1
|
||||
ORDER BY owner_user_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []duplicateGroup
|
||||
for rows.Next() {
|
||||
var g duplicateGroup
|
||||
if err := rows.Scan(&g.OwnerUserID, &g.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
func buildPlan(db *sql.DB, ownerUserID int64) (mergePlan, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT us.id, us.user_id, us.order_id, us.subscribe_id, us.expire_time, us.traffic,
|
||||
us.download, us.upload, us.expired_download, us.expired_upload, us.status, us.updated_at
|
||||
FROM user_subscribe us
|
||||
LEFT JOIN user_family_member ufm
|
||||
ON ufm.user_id = us.user_id AND ufm.deleted_at IS NULL AND ufm.status = 1
|
||||
LEFT JOIN user_family uf
|
||||
ON uf.id = ufm.family_id AND uf.deleted_at IS NULL AND uf.status = 1
|
||||
WHERE COALESCE(uf.owner_user_id, us.user_id) = ?
|
||||
AND us.token <> ''
|
||||
AND us.status IN (0, 1, 2, 3, 4)
|
||||
ORDER BY us.expire_time DESC, us.updated_at DESC, us.id DESC`, ownerUserID)
|
||||
if err != nil {
|
||||
return mergePlan{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var all []subscriptionRow
|
||||
for rows.Next() {
|
||||
var r subscriptionRow
|
||||
if err := rows.Scan(&r.ID, &r.UserID, &r.OrderID, &r.SubscribeID, &r.ExpireTime, &r.Traffic, &r.Download, &r.Upload, &r.ExpiredDownload, &r.ExpiredUpload, &r.Status, &r.UpdatedAt); err != nil {
|
||||
return mergePlan{}, err
|
||||
}
|
||||
all = append(all, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return mergePlan{}, err
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return mergePlan{OwnerUserID: ownerUserID}, nil
|
||||
}
|
||||
|
||||
keep := all[0]
|
||||
for _, r := range all[1:] {
|
||||
keep.Download += r.Download
|
||||
keep.Upload += r.Upload
|
||||
keep.ExpiredDownload += r.ExpiredDownload
|
||||
keep.ExpiredUpload += r.ExpiredUpload
|
||||
if r.Traffic > keep.Traffic {
|
||||
keep.Traffic = r.Traffic
|
||||
}
|
||||
}
|
||||
for _, r := range all {
|
||||
if r.UpdatedAt.After(keep.UpdatedAt) {
|
||||
keep.SubscribeID = r.SubscribeID
|
||||
}
|
||||
}
|
||||
|
||||
return mergePlan{OwnerUserID: ownerUserID, Keep: keep, Merge: all[1:]}, nil
|
||||
}
|
||||
|
||||
func applyPlan(db *sql.DB, plan mergePlan) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err = tx.Exec(`
|
||||
UPDATE user_subscribe
|
||||
SET user_id = ?, subscribe_id = ?, traffic = ?, download = ?, upload = ?,
|
||||
expired_download = ?, expired_upload = ?, status = 1, note = CONCAT(COALESCE(note, ''), ' [merged duplicate subscriptions]')
|
||||
WHERE id = ?`,
|
||||
plan.OwnerUserID, plan.Keep.SubscribeID, plan.Keep.Traffic, plan.Keep.Download, plan.Keep.Upload,
|
||||
plan.Keep.ExpiredDownload, plan.Keep.ExpiredUpload, plan.Keep.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range plan.Merge {
|
||||
if _, err = tx.Exec(`
|
||||
UPDATE user_subscribe
|
||||
SET status = 5, note = CONCAT(COALESCE(note, ''), ' [merged into subscription #', ?, ']')
|
||||
WHERE id = ?`, plan.Keep.ID, r.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -1,875 +0,0 @@
|
||||
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
|
||||
UNION
|
||||
SELECT user_id AS uid FROM user_subscribe WHERE user_id > 0 AND (expire_time IS NULL OR expire_time > NOW())
|
||||
) t
|
||||
INNER JOIN user u ON u.id = t.uid
|
||||
WHERE u.id NOT IN (
|
||||
SELECT user_id FROM user_auth_methods WHERE auth_type = 'email' AND auth_identifier = 'devneeds52@gmail.com'
|
||||
)
|
||||
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
|
||||
|
||||
// ── 为了配合基于新 UID 的条件检查,预先构建映射 ──
|
||||
deviceCountByNewUID := make(map[int64]int)
|
||||
for i := range devices {
|
||||
deviceCountByNewUID[devices[i].UserId]++
|
||||
}
|
||||
hasEmailByNewUID := make(map[int64]bool)
|
||||
for i := range auths {
|
||||
if auths[i].AuthType == "email" {
|
||||
hasEmailByNewUID[auths[i].UserId] = true
|
||||
}
|
||||
}
|
||||
|
||||
err = dstDB.Transaction(func(tx *gorm.DB) error {
|
||||
for _, uid := range paidIDs {
|
||||
// 只为多设备且有邮箱的用户创建家庭组
|
||||
if deviceCountByNewUID[uid] <= 1 || !hasEmailByNewUID[uid] {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 仅仅当原用户有邮箱时,才尝试将其加入家庭组(无邮箱的仅拆分为独立用户)
|
||||
if hasEmailByNewUID[s.OwnerUID] {
|
||||
// 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 }
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,787 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
authlogic "github.com/perfect-panel/server/internal/logic/auth"
|
||||
modelLog "github.com/perfect-panel/server/internal/model/log"
|
||||
modelOrder "github.com/perfect-panel/server/internal/model/order"
|
||||
modelSubscribe "github.com/perfect-panel/server/internal/model/subscribe"
|
||||
modelUser "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
"github.com/perfect-panel/server/pkg/orm"
|
||||
"github.com/perfect-panel/server/pkg/uuidx"
|
||||
orderLogic "github.com/perfect-panel/server/queue/logic/order"
|
||||
queueTypes "github.com/perfect-panel/server/queue/types"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const marker = "codex-replay-business-bugs"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
configPath = flag.String("config", "etc/ppanel.yaml", "ppanel config path for test server DB/Redis")
|
||||
dsn = flag.String("dsn", "", "optional MySQL DSN override: user:pass@tcp(host:3306)/db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai")
|
||||
writeDB = flag.Bool("write-db", false, "create isolated test rows and execute activation replay against the configured test DB")
|
||||
force = flag.Bool("force", false, "allow -write-db even when the config name does not clearly look like test/dev/staging")
|
||||
keep = flag.Bool("keep", false, "keep replay rows for manual inspection")
|
||||
cleanupOnly = flag.Bool("cleanup-only", false, "delete leftover replay rows by marker and exit")
|
||||
skipCodeTests = flag.Bool("skip-code-tests", false, "skip go test checks")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
ctx := context.Background()
|
||||
started := time.Now()
|
||||
fmt.Println("== replay business bug tests ==")
|
||||
fmt.Printf("marker: %s\n", marker)
|
||||
|
||||
if !*skipCodeTests {
|
||||
must(runCodeTests())
|
||||
}
|
||||
|
||||
cfg := loadConfig(*configPath, *dsn)
|
||||
runEmailTrialAssertions(cfg)
|
||||
|
||||
if *cleanupOnly {
|
||||
env := mustNewReplayEnv(ctx, cfg)
|
||||
env.cleanupByMarker(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
if !*writeDB {
|
||||
fmt.Println("\nDB replay skipped. Add -write-db to create isolated rows in the TEST database and run activation flows.")
|
||||
fmt.Println("Example:")
|
||||
fmt.Printf(" go run scripts/replay_business_bugs.go -config %s -write-db\n", *configPath)
|
||||
return
|
||||
}
|
||||
|
||||
if looksLikeProduction(cfg) && !*force {
|
||||
fatalf("refusing to write DB because config does not look like a test environment: db=%s host=%s; add -force only on the test server", cfg.MySQL.Dbname, cfg.Site.Host)
|
||||
}
|
||||
|
||||
env := mustNewReplayEnv(ctx, cfg)
|
||||
if !*keep {
|
||||
defer env.cleanup(ctx)
|
||||
}
|
||||
|
||||
must(env.replaySingleSubscription(ctx))
|
||||
must(env.replayInviteRulesMatrix(ctx))
|
||||
must(env.replayFamilyInviteGiftToOwner(ctx))
|
||||
|
||||
fmt.Printf("\nPASS all replay checks in %s\n", time.Since(started).Round(time.Millisecond))
|
||||
if *keep {
|
||||
fmt.Println("Replay rows kept for inspection. Delete rows with remark/name/order_no containing:", marker)
|
||||
}
|
||||
}
|
||||
|
||||
func runCodeTests() error {
|
||||
fmt.Println("\n-- code-level tests --")
|
||||
args := []string{"test",
|
||||
"./internal/logic/auth",
|
||||
"./internal/logic/common",
|
||||
"./internal/logic/public/order",
|
||||
"./queue/logic/order",
|
||||
}
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("go test failed: %w", err)
|
||||
}
|
||||
fmt.Println("PASS code-level tests")
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadConfig(path, dsn string) config.Config {
|
||||
var cfg config.Config
|
||||
conf.MustLoad(path, &cfg)
|
||||
if dsn != "" {
|
||||
cfg.MySQL = parseDSN(dsn)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func parseDSN(dsn string) orm.Config {
|
||||
cfg := orm.ParseDSN(dsn)
|
||||
if cfg == nil {
|
||||
fatalf("invalid dsn")
|
||||
}
|
||||
return *cfg
|
||||
}
|
||||
|
||||
func runEmailTrialAssertions(cfg config.Config) {
|
||||
fmt.Println("\n-- bug1 email trial whitelist assertions --")
|
||||
cfg.Register.EnableTrial = true
|
||||
cfg.Register.EnableTrialEmailWhitelist = true
|
||||
if cfg.Register.TrialEmailDomainWhitelist == "" {
|
||||
cfg.Register.TrialEmailDomainWhitelist = "gmail.com,163.com"
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
email string
|
||||
want bool
|
||||
}{
|
||||
{"1.2.3.4xxx@gmaial.com", false},
|
||||
{"a.b.c@gmail.com", false},
|
||||
{"user+tag@gmail.com", false},
|
||||
{"user@fake.gmail.com", false},
|
||||
{"normaluser@gmail.com", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := authlogic.ShouldGrantTrialForEmail(cfg.Register, tc.email)
|
||||
if got != tc.want {
|
||||
fatalf("email trial assertion failed: email=%s got=%v want=%v", tc.email, got, tc.want)
|
||||
}
|
||||
fmt.Printf("PASS %-32s grant=%v\n", tc.email, got)
|
||||
}
|
||||
}
|
||||
|
||||
type replayEnv struct {
|
||||
db *gorm.DB
|
||||
rds *redis.Client
|
||||
cfg config.Config
|
||||
svcCtx *svc.ServiceContext
|
||||
ids struct {
|
||||
users []int64
|
||||
subscribes []int64
|
||||
plans []int64
|
||||
orders []int64
|
||||
logs []int64
|
||||
}
|
||||
}
|
||||
|
||||
func mustNewReplayEnv(ctx context.Context, cfg config.Config) *replayEnv {
|
||||
fmt.Println("\n-- connecting test DB/Redis --")
|
||||
db, err := orm.ConnectMysql(orm.Mysql{Config: cfg.MySQL})
|
||||
must(err)
|
||||
rds := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Host,
|
||||
Password: cfg.Redis.Pass,
|
||||
DB: cfg.Redis.DB,
|
||||
PoolSize: cfg.Redis.PoolSize,
|
||||
MinIdleConns: cfg.Redis.MinIdleConns,
|
||||
})
|
||||
must(rds.Ping(ctx).Err())
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
DB: db,
|
||||
Redis: rds,
|
||||
Config: cfg,
|
||||
UserModel: modelUser.NewModel(db, rds),
|
||||
OrderModel: modelOrder.NewModel(db, rds),
|
||||
SubscribeModel: modelSubscribe.NewModel(db, rds),
|
||||
LogModel: modelLog.NewModel(db),
|
||||
}
|
||||
fmt.Printf("connected: mysql=%s/%s redis=%s\n", cfg.MySQL.Addr, cfg.MySQL.Dbname, cfg.Redis.Host)
|
||||
return &replayEnv{db: db, rds: rds, cfg: cfg, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (e *replayEnv) replaySingleSubscription(ctx context.Context) error {
|
||||
fmt.Println("\n-- bug2 replay: paid purchase must reuse existing subscription --")
|
||||
planA, planB, err := e.createPlans(ctx, "bug2")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owner, err := e.createUser(ctx, "bug2-owner", 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := e.createUserSubscribe(ctx, owner.Id, 0, planA.Id, time.Now().Add(7*24*time.Hour))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
order, err := e.createPaidOrder(ctx, owner.Id, owner.Id, planB.Id, true, "bug2")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: order.OrderNo})
|
||||
worker := orderLogic.NewActivateOrderLogic(e.svcCtx)
|
||||
if err = worker.ProcessTask(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = worker.ProcessTask(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var rows []modelUser.Subscribe
|
||||
if err = e.db.WithContext(ctx).
|
||||
Where("user_id = ? AND token <> '' AND status IN ?", owner.Id, []int{0, 1, 2, 3, 4}).
|
||||
Order("id ASC").
|
||||
Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
return fmt.Errorf("bug2 failed: expected one visible subscription, got %d", len(rows))
|
||||
}
|
||||
if rows[0].Id != existing.Id {
|
||||
return fmt.Errorf("bug2 failed: expected original subscription id=%d to be reused, got id=%d", existing.Id, rows[0].Id)
|
||||
}
|
||||
if rows[0].SubscribeId != planB.Id || rows[0].OrderId != order.Id {
|
||||
return fmt.Errorf("bug2 failed: reused subscription not updated, subscribe_id=%d order_id=%d", rows[0].SubscribeId, rows[0].OrderId)
|
||||
}
|
||||
fmt.Printf("PASS user=%d user_subscribe=%d plan %d -> %d order=%s\n", owner.Id, rows[0].Id, planA.Id, planB.Id, order.OrderNo)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *replayEnv) replayInviteGiftDays(ctx context.Context) error {
|
||||
fmt.Println("\n-- bug3 replay: commission=0 invite should grant gift days to both users --")
|
||||
giftDays := e.cfg.Invite.GiftDays
|
||||
if giftDays <= 0 {
|
||||
giftDays = 2
|
||||
e.svcCtx.Config.Invite.GiftDays = giftDays
|
||||
}
|
||||
e.svcCtx.Config.Invite.ReferralPercentage = 0
|
||||
e.svcCtx.Config.Invite.OnlyFirstPurchase = true
|
||||
|
||||
planA, _, err := e.createPlans(ctx, "bug3")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
referer, err := e.createUser(ctx, "bug3-referer", 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
referee, err := e.createUser(ctx, "bug3-referee", referer.Id, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseExpire := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||
refererSub, err := e.createUserSubscribe(ctx, referer.Id, 0, planA.Id, baseExpire)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refereeSub, err := e.createUserSubscribe(ctx, referee.Id, 0, planA.Id, baseExpire)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
order, err := e.createPaidOrder(ctx, referee.Id, referee.Id, planA.Id, true, "bug3")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: order.OrderNo})
|
||||
worker := orderLogic.NewActivateOrderLogic(e.svcCtx)
|
||||
if err = worker.ProcessTask(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = e.waitForGiftLogs(ctx, order.OrderNo, referer.Id, referee.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var refererAfter, refereeAfter modelUser.Subscribe
|
||||
if err = e.db.WithContext(ctx).First(&refererAfter, refererSub.Id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = e.db.WithContext(ctx).First(&refereeAfter, refereeSub.Id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
minRefererExpire := baseExpire.Add(time.Duration(giftDays) * 24 * time.Hour)
|
||||
if refererAfter.ExpireTime.Before(minRefererExpire.Add(-time.Second)) {
|
||||
return fmt.Errorf("bug3 failed: referer expire not increased by gift days, got=%s want>=%s", refererAfter.ExpireTime, minRefererExpire)
|
||||
}
|
||||
if !refereeAfter.ExpireTime.After(baseExpire) {
|
||||
return fmt.Errorf("bug3 failed: referee expire did not increase, got=%s base=%s", refereeAfter.ExpireTime, baseExpire)
|
||||
}
|
||||
|
||||
// Idempotency: repeat the same order task and make sure gift logs are still one per user.
|
||||
if err = worker.ProcessTask(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)); err != nil {
|
||||
return err
|
||||
}
|
||||
var giftCount int64
|
||||
if err = e.db.WithContext(ctx).Model(&modelLog.SystemLog{}).
|
||||
Where("type = ? AND object_id IN ? AND content LIKE ?", modelLog.TypeGift.Uint8(), []int64{referer.Id, referee.Id}, "%"+order.OrderNo+"%").
|
||||
Count(&giftCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if giftCount != 2 {
|
||||
return fmt.Errorf("bug3 failed: expected 2 gift logs after duplicate task, got %d", giftCount)
|
||||
}
|
||||
fmt.Printf("PASS referer=%d referee=%d order=%s gift_days=%d logs=%d\n", referer.Id, referee.Id, order.OrderNo, giftDays, giftCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *replayEnv) replayInviteRulesMatrix(ctx context.Context) error {
|
||||
fmt.Println("\n-- bug3 replay matrix: invite gift/commission rules --")
|
||||
giftDays := e.cfg.Invite.GiftDays
|
||||
if giftDays <= 0 {
|
||||
giftDays = 2
|
||||
}
|
||||
e.svcCtx.Config.Invite.GiftDays = giftDays
|
||||
e.svcCtx.Config.Invite.OnlyFirstPurchase = false
|
||||
|
||||
planA, _, err := e.createPlans(ctx, "bug3-matrix")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
hasReferer bool
|
||||
globalReferralPct int64
|
||||
isNewOrder bool
|
||||
wantGiftLogs int64
|
||||
wantCommissionLogs int64
|
||||
wantCommission int64
|
||||
}{
|
||||
{
|
||||
name: "no invite relation first order no gift",
|
||||
hasReferer: false,
|
||||
isNewOrder: true,
|
||||
wantGiftLogs: 0,
|
||||
},
|
||||
{
|
||||
name: "ordinary invite commission 0 first order gifts both",
|
||||
hasReferer: true,
|
||||
isNewOrder: true,
|
||||
wantGiftLogs: 2,
|
||||
},
|
||||
{
|
||||
name: "ordinary invite commission 0 non-first order no gift",
|
||||
hasReferer: true,
|
||||
isNewOrder: false,
|
||||
wantGiftLogs: 0,
|
||||
},
|
||||
{
|
||||
name: "channel commission positive first order gifts referee only",
|
||||
hasReferer: true,
|
||||
globalReferralPct: 10,
|
||||
isNewOrder: true,
|
||||
wantGiftLogs: 1,
|
||||
wantCommissionLogs: 1,
|
||||
wantCommission: 59,
|
||||
},
|
||||
{
|
||||
name: "channel commission positive non-first order commission only",
|
||||
hasReferer: true,
|
||||
globalReferralPct: 10,
|
||||
isNewOrder: false,
|
||||
wantGiftLogs: 0,
|
||||
wantCommissionLogs: 1,
|
||||
wantCommission: 59,
|
||||
},
|
||||
}
|
||||
|
||||
for idx, tc := range cases {
|
||||
e.svcCtx.Config.Invite.ReferralPercentage = tc.globalReferralPct
|
||||
scope := fmt.Sprintf("bug3-rule-%d", idx+1)
|
||||
var referer *modelUser.User
|
||||
if tc.hasReferer {
|
||||
referer, err = e.createUser(ctx, scope+"-referer", 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = e.createUserSubscribe(ctx, referer.Id, 0, planA.Id, time.Now().Add(10*24*time.Hour)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var refererID int64
|
||||
if referer != nil {
|
||||
refererID = referer.Id
|
||||
}
|
||||
referee, err := e.createUser(ctx, scope+"-referee", refererID, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = e.createUserSubscribe(ctx, referee.Id, 0, planA.Id, time.Now().Add(10*24*time.Hour)); err != nil {
|
||||
return err
|
||||
}
|
||||
order, err := e.createPaidOrder(ctx, referee.Id, referee.Id, planA.Id, tc.isNewOrder, scope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = e.activateOrderTwice(ctx, order.OrderNo); err != nil {
|
||||
return fmt.Errorf("%s: %w", tc.name, err)
|
||||
}
|
||||
if err = e.waitForLogCounts(ctx, order.OrderNo, tc.wantGiftLogs, tc.wantCommissionLogs); err != nil {
|
||||
return fmt.Errorf("%s: %w", tc.name, err)
|
||||
}
|
||||
giftLogs, err := e.countLogs(ctx, modelLog.TypeGift.Uint8(), order.OrderNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commissionLogs, err := e.countLogs(ctx, modelLog.TypeCommission.Uint8(), order.OrderNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if giftLogs != tc.wantGiftLogs {
|
||||
return fmt.Errorf("%s: expected gift logs=%d got=%d", tc.name, tc.wantGiftLogs, giftLogs)
|
||||
}
|
||||
if commissionLogs != tc.wantCommissionLogs {
|
||||
return fmt.Errorf("%s: expected commission logs=%d got=%d", tc.name, tc.wantCommissionLogs, commissionLogs)
|
||||
}
|
||||
if referer != nil && tc.wantCommission > 0 {
|
||||
var after modelUser.User
|
||||
if err = e.db.WithContext(ctx).First(&after, referer.Id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if after.Commission != tc.wantCommission {
|
||||
return fmt.Errorf("%s: expected referer commission=%d got=%d", tc.name, tc.wantCommission, after.Commission)
|
||||
}
|
||||
}
|
||||
fmt.Printf("PASS %-58s gifts=%d commission_logs=%d\n", tc.name, giftLogs, commissionLogs)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *replayEnv) replayFamilyInviteGiftToOwner(ctx context.Context) error {
|
||||
fmt.Println("\n-- bug3 family replay: member purchase gift days go to owner --")
|
||||
giftDays := e.cfg.Invite.GiftDays
|
||||
if giftDays <= 0 {
|
||||
giftDays = 2
|
||||
}
|
||||
e.svcCtx.Config.Invite.GiftDays = giftDays
|
||||
e.svcCtx.Config.Invite.ReferralPercentage = 0
|
||||
e.svcCtx.Config.Invite.OnlyFirstPurchase = false
|
||||
|
||||
planA, _, err := e.createPlans(ctx, "bug3-family")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
referer, err := e.createUser(ctx, "bug3-family-referer", 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owner, err := e.createUser(ctx, "bug3-family-owner", 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
member, err := e.createUser(ctx, "bug3-family-member", referer.Id, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = e.createFamily(ctx, owner.Id, member.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseExpire := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Millisecond)
|
||||
ownerSub, err := e.createUserSubscribe(ctx, owner.Id, 0, planA.Id, baseExpire)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
memberSub, err := e.createUserSubscribe(ctx, member.Id, 0, planA.Id, baseExpire)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refererSub, err := e.createUserSubscribe(ctx, referer.Id, 0, planA.Id, baseExpire)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
order, err := e.createPaidOrder(ctx, member.Id, owner.Id, planA.Id, true, "bug3-family")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = e.activateOrderTwice(ctx, order.OrderNo); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = e.waitForLogCounts(ctx, order.OrderNo, 2, 0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var ownerAfter, memberAfter, refererAfter modelUser.Subscribe
|
||||
if err = e.db.WithContext(ctx).First(&ownerAfter, ownerSub.Id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = e.db.WithContext(ctx).First(&memberAfter, memberSub.Id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err = e.db.WithContext(ctx).First(&refererAfter, refererSub.Id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !ownerAfter.ExpireTime.After(baseExpire) {
|
||||
return fmt.Errorf("family gift failed: owner expire not increased")
|
||||
}
|
||||
if !refererAfter.ExpireTime.After(baseExpire) {
|
||||
return fmt.Errorf("family gift failed: referer expire not increased")
|
||||
}
|
||||
if memberAfter.ExpireTime.After(baseExpire.Add(time.Second)) {
|
||||
return fmt.Errorf("family gift failed: member subscription should not receive gift days")
|
||||
}
|
||||
|
||||
var memberGiftLogs int64
|
||||
if err = e.db.WithContext(ctx).Model(&modelLog.SystemLog{}).
|
||||
Where("type = ? AND object_id = ? AND content LIKE ?", modelLog.TypeGift.Uint8(), member.Id, "%"+order.OrderNo+"%").
|
||||
Count(&memberGiftLogs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if memberGiftLogs != 0 {
|
||||
return fmt.Errorf("family gift failed: expected no member gift logs, got %d", memberGiftLogs)
|
||||
}
|
||||
fmt.Printf("PASS family member purchase gift target owner owner=%d member=%d referer=%d gift_days=%d\n", owner.Id, member.Id, referer.Id, giftDays)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *replayEnv) activateOrderTwice(ctx context.Context, orderNo string) error {
|
||||
payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: orderNo})
|
||||
worker := orderLogic.NewActivateOrderLogic(e.svcCtx)
|
||||
if err := worker.ProcessTask(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)); err != nil {
|
||||
return err
|
||||
}
|
||||
return worker.ProcessTask(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload))
|
||||
}
|
||||
|
||||
func (e *replayEnv) waitForLogCounts(ctx context.Context, orderNo string, wantGiftLogs, wantCommissionLogs int64) error {
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for {
|
||||
giftLogs, err := e.countLogs(ctx, modelLog.TypeGift.Uint8(), orderNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commissionLogs, err := e.countLogs(ctx, modelLog.TypeCommission.Uint8(), orderNo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if giftLogs >= wantGiftLogs && commissionLogs >= wantCommissionLogs {
|
||||
if wantGiftLogs == 0 && wantCommissionLogs == 0 {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timed out waiting for logs: order=%s gift=%d/%d commission=%d/%d", orderNo, giftLogs, wantGiftLogs, commissionLogs, wantCommissionLogs)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *replayEnv) countLogs(ctx context.Context, logType uint8, orderNo string) (int64, error) {
|
||||
var count int64
|
||||
err := e.db.WithContext(ctx).Model(&modelLog.SystemLog{}).
|
||||
Where("type = ? AND content LIKE ?", logType, "%"+orderNo+"%").
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (e *replayEnv) waitForGiftLogs(ctx context.Context, orderNo string, userIDs ...int64) error {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var count int64
|
||||
if err := e.db.WithContext(ctx).Model(&modelLog.SystemLog{}).
|
||||
Where("type = ? AND object_id IN ? AND content LIKE ?", modelLog.TypeGift.Uint8(), userIDs, "%"+orderNo+"%").
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == int64(len(userIDs)) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("timed out waiting for gift logs for order=%s", orderNo)
|
||||
}
|
||||
|
||||
func (e *replayEnv) createPlans(ctx context.Context, scope string) (*modelSubscribe.Subscribe, *modelSubscribe.Subscribe, error) {
|
||||
a := &modelSubscribe.Subscribe{
|
||||
Name: marker + "-" + scope + "-A",
|
||||
Language: "en",
|
||||
UnitPrice: 599,
|
||||
UnitTime: "Month",
|
||||
Traffic: 1024 * 1024 * 1024,
|
||||
Inventory: -1,
|
||||
Quota: 0,
|
||||
NodeGroupIds: modelSubscribe.JSONInt64Slice{},
|
||||
}
|
||||
b := &modelSubscribe.Subscribe{
|
||||
Name: marker + "-" + scope + "-B",
|
||||
Language: "en",
|
||||
UnitPrice: 699,
|
||||
UnitTime: "Month",
|
||||
Traffic: 2 * 1024 * 1024 * 1024,
|
||||
Inventory: -1,
|
||||
Quota: 0,
|
||||
NodeGroupIds: modelSubscribe.JSONInt64Slice{},
|
||||
}
|
||||
if err := e.db.WithContext(ctx).Create(a).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := e.db.WithContext(ctx).Create(b).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
e.ids.plans = append(e.ids.plans, a.Id, b.Id)
|
||||
return a, b, nil
|
||||
}
|
||||
|
||||
func (e *replayEnv) createUser(ctx context.Context, scope string, refererID int64, referralPercentage uint8) (*modelUser.User, error) {
|
||||
onlyFirst := true
|
||||
enable := true
|
||||
isAdmin := false
|
||||
u := &modelUser.User{
|
||||
Password: marker,
|
||||
Algo: "default",
|
||||
Salt: "default",
|
||||
RefererId: refererID,
|
||||
ReferralPercentage: referralPercentage,
|
||||
OnlyFirstPurchase: &onlyFirst,
|
||||
Enable: &enable,
|
||||
IsAdmin: &isAdmin,
|
||||
EnableBalanceNotify: &enable,
|
||||
EnableLoginNotify: &enable,
|
||||
EnableSubscribeNotify: &enable,
|
||||
EnableTradeNotify: &enable,
|
||||
Remark: marker + "-" + scope,
|
||||
}
|
||||
if err := e.db.WithContext(ctx).Create(u).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.ReferCode = uuidx.UserInviteCode(u.Id)
|
||||
if err := e.db.WithContext(ctx).Model(&modelUser.User{}).Where("id = ?", u.Id).Update("refer_code", u.ReferCode).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.ids.users = append(e.ids.users, u.Id)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (e *replayEnv) createFamily(ctx context.Context, ownerID, memberID int64) error {
|
||||
now := time.Now()
|
||||
family := &modelUser.UserFamily{
|
||||
OwnerUserId: ownerID,
|
||||
MaxMembers: modelUser.DefaultFamilyMaxSize,
|
||||
Status: modelUser.FamilyStatusActive,
|
||||
}
|
||||
if err := e.db.WithContext(ctx).Create(family).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
members := []modelUser.UserFamilyMember{
|
||||
{
|
||||
FamilyId: family.Id,
|
||||
UserId: ownerID,
|
||||
Role: modelUser.FamilyRoleOwner,
|
||||
Status: modelUser.FamilyMemberActive,
|
||||
JoinSource: marker,
|
||||
JoinedAt: now,
|
||||
},
|
||||
{
|
||||
FamilyId: family.Id,
|
||||
UserId: memberID,
|
||||
Role: modelUser.FamilyRoleMember,
|
||||
Status: modelUser.FamilyMemberActive,
|
||||
JoinSource: marker,
|
||||
JoinedAt: now,
|
||||
},
|
||||
}
|
||||
return e.db.WithContext(ctx).Create(&members).Error
|
||||
}
|
||||
|
||||
func (e *replayEnv) createUserSubscribe(ctx context.Context, userID, orderID, planID int64, expire time.Time) (*modelUser.Subscribe, error) {
|
||||
groupLocked := false
|
||||
sub := &modelUser.Subscribe{
|
||||
UserId: userID,
|
||||
OrderId: orderID,
|
||||
SubscribeId: planID,
|
||||
GroupLocked: &groupLocked,
|
||||
StartTime: time.Now().Add(-time.Hour),
|
||||
ExpireTime: expire,
|
||||
Traffic: 1024 * 1024 * 1024,
|
||||
Token: marker + "-" + uuidx.NewUUID().String(),
|
||||
UUID: uuidx.NewUUID().String(),
|
||||
Status: 1,
|
||||
Note: marker,
|
||||
}
|
||||
if err := e.db.WithContext(ctx).Create(sub).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.ids.subscribes = append(e.ids.subscribes, sub.Id)
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (e *replayEnv) createPaidOrder(ctx context.Context, userID, subscriptionUserID, planID int64, isNew bool, scope string) (*modelOrder.Order, error) {
|
||||
orderNo := fmt.Sprintf("%s-%s-%d", marker, scope, time.Now().UnixNano())
|
||||
order := &modelOrder.Order{
|
||||
UserId: userID,
|
||||
SubscriptionUserId: subscriptionUserID,
|
||||
OrderNo: orderNo,
|
||||
Type: 1,
|
||||
Quantity: 1,
|
||||
Price: 599,
|
||||
Amount: 599,
|
||||
Status: 2,
|
||||
SubscribeId: planID,
|
||||
Method: "replay",
|
||||
IsNew: isNew,
|
||||
}
|
||||
if err := e.db.WithContext(ctx).Create(order).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.ids.orders = append(e.ids.orders, order.Id)
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (e *replayEnv) cleanup(ctx context.Context) {
|
||||
fmt.Println("\n-- cleanup replay rows --")
|
||||
e.cleanupByMarker(ctx)
|
||||
if len(e.ids.subscribes) > 0 {
|
||||
_ = e.db.WithContext(ctx).Where("id IN ?", e.ids.subscribes).Delete(&modelUser.Subscribe{}).Error
|
||||
}
|
||||
if len(e.ids.orders) > 0 {
|
||||
_ = e.db.WithContext(ctx).Where("id IN ?", e.ids.orders).Delete(&modelOrder.Order{}).Error
|
||||
}
|
||||
if len(e.ids.plans) > 0 {
|
||||
_ = e.db.WithContext(ctx).Where("id IN ?", e.ids.plans).Delete(&modelSubscribe.Subscribe{}).Error
|
||||
}
|
||||
if len(e.ids.users) > 0 {
|
||||
_ = e.db.WithContext(ctx).Unscoped().Where("id IN ?", e.ids.users).Delete(&modelUser.User{}).Error
|
||||
}
|
||||
fmt.Println("cleanup done")
|
||||
}
|
||||
|
||||
func (e *replayEnv) cleanupByMarker(ctx context.Context) {
|
||||
_ = e.db.WithContext(ctx).
|
||||
Where("join_source = ?", marker).
|
||||
Delete(&modelUser.UserFamilyMember{}).Error
|
||||
_ = e.db.WithContext(ctx).
|
||||
Where("owner_user_id IN (SELECT id FROM `user` WHERE remark LIKE ?)", marker+"%").
|
||||
Delete(&modelUser.UserFamily{}).Error
|
||||
_ = e.db.WithContext(ctx).
|
||||
Where("type IN (33, 34) AND content LIKE ?", "%"+marker+"%").
|
||||
Delete(&modelLog.SystemLog{}).Error
|
||||
_ = e.db.WithContext(ctx).
|
||||
Where("order_no LIKE ?", marker+"%").
|
||||
Delete(&modelOrder.Order{}).Error
|
||||
_ = e.db.WithContext(ctx).
|
||||
Where("note = ? OR token LIKE ?", marker, marker+"%").
|
||||
Delete(&modelUser.Subscribe{}).Error
|
||||
_ = e.db.WithContext(ctx).
|
||||
Where("name LIKE ?", marker+"%").
|
||||
Delete(&modelSubscribe.Subscribe{}).Error
|
||||
_ = e.db.WithContext(ctx).Unscoped().
|
||||
Where("remark LIKE ?", marker+"%").
|
||||
Delete(&modelUser.User{}).Error
|
||||
}
|
||||
|
||||
func looksLikeProduction(cfg config.Config) bool {
|
||||
joined := strings.ToLower(strings.Join([]string{cfg.MySQL.Dbname, cfg.Site.Host, cfg.Host}, " "))
|
||||
if strings.Contains(joined, "prod") || strings.Contains(joined, "production") {
|
||||
return true
|
||||
}
|
||||
if cfg.Debug {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(joined, "test") || strings.Contains(joined, "dev") || strings.Contains(joined, "staging") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...interface{}) {
|
||||
fmt.Fprintf(os.Stderr, "FAIL: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
package main
|
||||
|
||||
// Apple App Store Server API 测试脚本
|
||||
// 用法: go run scripts/test_apple_lookup.go
|
||||
// 功能: 通过 Apple Server API 获取交易历史,并按 appAccountToken (UUID) 过滤匹配交易
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ==================== 配置区域 ====================
|
||||
// 请填入你的 Apple App Store Connect API 凭证
|
||||
const (
|
||||
keyID = "" // App Store Connect Key ID
|
||||
issuerID = "" // App Store Connect Issuer ID
|
||||
bundleID = "com.hifastvpn.vip" // 你的 App Bundle ID
|
||||
sandbox = true // true=沙盒环境, false=生产环境
|
||||
)
|
||||
|
||||
// 私钥内容 (PEM 格式)
|
||||
// 从 App Store Connect 下载的 .p8 文件内容
|
||||
var privateKeyPEM = ``
|
||||
|
||||
// ==================== 主逻辑 ====================
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Println("用法:")
|
||||
fmt.Println(" go run scripts/test_apple_lookup.go <originalTransactionId> [appAccountToken]")
|
||||
fmt.Println("")
|
||||
fmt.Println("参数:")
|
||||
fmt.Println(" originalTransactionId Apple 交易原始 ID")
|
||||
fmt.Println(" appAccountToken 可选, 用于过滤的 UUID (服务端下单时生成)")
|
||||
fmt.Println("")
|
||||
fmt.Println("示例:")
|
||||
fmt.Println(" go run scripts/test_apple_lookup.go 2000001132940893")
|
||||
fmt.Println(" go run scripts/test_apple_lookup.go 2000001132940893 f0eb8c62-4be9-4be7-9266-58d1a0a4e7bf")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
originalTransactionId := os.Args[1]
|
||||
filterToken := ""
|
||||
if len(os.Args) >= 3 {
|
||||
filterToken = os.Args[2]
|
||||
}
|
||||
|
||||
if keyID == "" || issuerID == "" || privateKeyPEM == "" {
|
||||
fmt.Println("❌ 请先在脚本中填入 Apple API 凭证 (keyID, issuerID, privateKeyPEM)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("═══════════════════════════════════════════════")
|
||||
fmt.Println(" Apple App Store Server API - 交易历史查询")
|
||||
fmt.Println("═══════════════════════════════════════════════")
|
||||
fmt.Printf(" 环境: %s\n", envName())
|
||||
fmt.Printf(" TransactionID: %s\n", originalTransactionId)
|
||||
if filterToken != "" {
|
||||
fmt.Printf(" 过滤 Token: %s\n", filterToken)
|
||||
}
|
||||
fmt.Println("═══════════════════════════════════════════════")
|
||||
|
||||
// 1. 生成 JWT
|
||||
token, err := buildJWT()
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 生成 JWT 失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("✅ JWT 生成成功")
|
||||
|
||||
// 2. 查询交易历史
|
||||
fmt.Printf("\n📡 正在查询交易历史...\n")
|
||||
transactions, err := getTransactionHistory(token, originalTransactionId)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 查询失败: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ 共获取 %d 条交易记录\n\n", len(transactions))
|
||||
|
||||
// 3. 解析并展示交易
|
||||
for i, jws := range transactions {
|
||||
info, err := parseJWS(jws)
|
||||
if err != nil {
|
||||
fmt.Printf(" [%d] ❌ 解析失败: %v\n", i+1, err)
|
||||
continue
|
||||
}
|
||||
|
||||
txToken := info["appAccountToken"]
|
||||
matched := ""
|
||||
if filterToken != "" && fmt.Sprintf("%v", txToken) == filterToken {
|
||||
matched = " ✅ 匹配!"
|
||||
}
|
||||
|
||||
fmt.Printf(" [%d]%s\n", i+1, matched)
|
||||
fmt.Printf(" TransactionID: %v\n", info["transactionId"])
|
||||
fmt.Printf(" OriginalTxID: %v\n", info["originalTransactionId"])
|
||||
fmt.Printf(" ProductID: %v\n", info["productId"])
|
||||
fmt.Printf(" AppAccountToken: %v\n", txToken)
|
||||
fmt.Printf(" PurchaseDate: %v\n", formatTimestamp(info["purchaseDate"]))
|
||||
fmt.Printf(" Type: %v\n", info["type"])
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// 4. 如果指定了过滤 token,显示匹配结果
|
||||
if filterToken != "" {
|
||||
found := false
|
||||
for _, jws := range transactions {
|
||||
info, err := parseJWS(jws)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if fmt.Sprintf("%v", info["appAccountToken"]) == filterToken {
|
||||
found = true
|
||||
fmt.Println("═══════════════════════════════════════════════")
|
||||
fmt.Printf("🎯 找到匹配的交易!AppAccountToken: %s\n", filterToken)
|
||||
fmt.Printf(" TransactionID: %v\n", info["transactionId"])
|
||||
fmt.Printf(" ProductID: %v\n", info["productId"])
|
||||
fmt.Println("═══════════════════════════════════════════════")
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
fmt.Println("═══════════════════════════════════════════════")
|
||||
fmt.Printf("⚠️ 未找到 AppAccountToken=%s 的交易\n", filterToken)
|
||||
fmt.Println("═══════════════════════════════════════════════")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Apple API 调用 ====================
|
||||
|
||||
// getTransactionHistory 获取指定交易 ID 的完整交易历史
|
||||
// Apple API: GET /inApps/v2/history/{transactionId}
|
||||
func getTransactionHistory(jwt, transactionId string) ([]string, error) {
|
||||
var allTransactions []string
|
||||
revision := ""
|
||||
|
||||
for {
|
||||
host := apiHost()
|
||||
url := fmt.Sprintf("%s/inApps/v2/history/%s?sort=DESCENDING", host, transactionId)
|
||||
if revision != "" {
|
||||
url += "&revision=" + revision
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+jwt)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("HTTP 请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
// 尝试另一个环境
|
||||
host2 := apiHostSecondary()
|
||||
url2 := fmt.Sprintf("%s/inApps/v2/history/%s?sort=DESCENDING", host2, transactionId)
|
||||
if revision != "" {
|
||||
url2 += "&revision=" + revision
|
||||
}
|
||||
req2, _ := http.NewRequest("GET", url2, nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+jwt)
|
||||
resp2, err2 := http.DefaultClient.Do(req2)
|
||||
if err2 != nil {
|
||||
return nil, fmt.Errorf("两个环境都失败: primary[%d:%s]", resp.StatusCode, string(body))
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
body2, _ := io.ReadAll(resp2.Body)
|
||||
if resp2.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("两个环境都失败: primary[%d:%s], secondary[%d:%s]",
|
||||
resp.StatusCode, string(body), resp2.StatusCode, string(body2))
|
||||
}
|
||||
body = body2
|
||||
}
|
||||
|
||||
var result struct {
|
||||
SignedTransactions []string `json:"signedTransactions"`
|
||||
Revision string `json:"revision"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("解析响应失败: %v, body: %s", err, string(body))
|
||||
}
|
||||
|
||||
allTransactions = append(allTransactions, result.SignedTransactions...)
|
||||
|
||||
if !result.HasMore || result.Revision == "" {
|
||||
break
|
||||
}
|
||||
revision = result.Revision
|
||||
}
|
||||
|
||||
return allTransactions, nil
|
||||
}
|
||||
|
||||
// ==================== JWT 构建 ====================
|
||||
|
||||
// buildJWT 构建 Apple Server API 的 ES256 JWT Token
|
||||
func buildJWT() (string, error) {
|
||||
header := map[string]interface{}{
|
||||
"alg": "ES256",
|
||||
"kid": keyID,
|
||||
"typ": "JWT",
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
payload := map[string]interface{}{
|
||||
"iss": issuerID,
|
||||
"iat": now,
|
||||
"exp": now + 1800,
|
||||
"aud": "appstoreconnect-v1",
|
||||
}
|
||||
if bundleID != "" {
|
||||
payload["bid"] = bundleID
|
||||
}
|
||||
|
||||
hb, _ := json.Marshal(header)
|
||||
pb, _ := json.Marshal(payload)
|
||||
enc := func(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
unsigned := fmt.Sprintf("%s.%s", enc(hb), enc(pb))
|
||||
|
||||
key := fixPEM(privateKeyPEM)
|
||||
block, _ := pem.Decode([]byte(key))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("invalid private key PEM")
|
||||
}
|
||||
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse private key failed: %v", err)
|
||||
}
|
||||
priv, ok := keyAny.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("private key is not ECDSA")
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
h.Write([]byte(unsigned))
|
||||
digest := h.Sum(nil)
|
||||
|
||||
r, s, err := ecdsa.Sign(rand.Reader, priv, digest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
curveBits := priv.Curve.Params().BitSize
|
||||
keyBytes := curveBits / 8
|
||||
if curveBits%8 > 0 {
|
||||
keyBytes++
|
||||
}
|
||||
rBytes := r.Bytes()
|
||||
rPadded := make([]byte, keyBytes)
|
||||
copy(rPadded[keyBytes-len(rBytes):], rBytes)
|
||||
sBytes := s.Bytes()
|
||||
sPadded := make([]byte, keyBytes)
|
||||
copy(sPadded[keyBytes-len(sBytes):], sBytes)
|
||||
|
||||
sig := append(rPadded, sPadded...)
|
||||
return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
// parseJWS 解析 Apple 返回的 JWS (只取 payload 部分)
|
||||
func parseJWS(jws string) (map[string]interface{}, error) {
|
||||
parts := strings.Split(jws, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid JWS format")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
// 尝试标准 base64
|
||||
payload, err = base64.RawStdEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode payload failed: %v", err)
|
||||
}
|
||||
}
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(payload, &result); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal payload failed: %v", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// formatTimestamp 格式化 Apple 返回的毫秒时间戳
|
||||
func formatTimestamp(v interface{}) string {
|
||||
if v == nil {
|
||||
return "N/A"
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
ts := time.UnixMilli(int64(t))
|
||||
return ts.Format("2006-01-02 15:04:05")
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func fixPEM(key string) string {
|
||||
if !strings.Contains(key, "\n") && strings.Contains(key, "BEGIN PRIVATE KEY") {
|
||||
key = strings.ReplaceAll(key, " ", "\n")
|
||||
key = strings.ReplaceAll(key, "-----BEGIN\nPRIVATE\nKEY-----", "-----BEGIN PRIVATE KEY-----")
|
||||
key = strings.ReplaceAll(key, "-----END\nPRIVATE\nKEY-----", "-----END PRIVATE KEY-----")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func apiHost() string {
|
||||
if sandbox {
|
||||
return "https://api.storekit-sandbox.itunes.apple.com"
|
||||
}
|
||||
return "https://api.storekit.itunes.apple.com"
|
||||
}
|
||||
|
||||
func apiHostSecondary() string {
|
||||
if sandbox {
|
||||
return "https://api.storekit.itunes.apple.com"
|
||||
}
|
||||
return "https://api.storekit-sandbox.itunes.apple.com"
|
||||
}
|
||||
|
||||
func envName() string {
|
||||
if sandbox {
|
||||
return "🏖️ Sandbox"
|
||||
}
|
||||
return "🏭 Production"
|
||||
}
|
||||
|
||||
// 忽略未使用导入
|
||||
var _ = bytes.NewBuffer
|
||||
@@ -1,295 +0,0 @@
|
||||
package main
|
||||
|
||||
// 设备登录测试脚本
|
||||
// 用法: go run scripts/test_device_login.go
|
||||
// 功能: 模拟客户端设备登录,自动加密请求体,解密响应,打印 token
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/forgoer/openssl"
|
||||
)
|
||||
|
||||
// ==================== 配置区域 ====================
|
||||
|
||||
const (
|
||||
serverURL = "https://tapi.hifast.biz" // 服务地址
|
||||
securitySecret = "c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx" // device.security_secret
|
||||
identifier = "test-device-script-001" // 设备唯一标识
|
||||
userAgent = "TestScript/1.0" // UserAgent
|
||||
|
||||
appId = "android-client" // AppSignature.AppSecrets 中的 key
|
||||
appSecret = "uB4G,XxL2{7b" // AppSignature.AppSecrets 中的 value
|
||||
)
|
||||
|
||||
// ==================== AES 工具(与服务端 pkg/aes/aes.go 一致)====================
|
||||
|
||||
func generateKey(key string) []byte {
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return hash[:32]
|
||||
}
|
||||
|
||||
func generateIv(iv, key string) []byte {
|
||||
h := md5.New()
|
||||
h.Write([]byte(iv))
|
||||
return generateKey(hex.EncodeToString(h.Sum(nil)) + key)
|
||||
}
|
||||
|
||||
func aesEncrypt(plainText []byte, keyStr string) (data string, nonce string, err error) {
|
||||
nonce = fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
key := generateKey(keyStr)
|
||||
iv := generateIv(nonce, keyStr)
|
||||
dst, err := openssl.AesCBCEncrypt(plainText, key, iv, openssl.PKCS7_PADDING)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(dst), nonce, nil
|
||||
}
|
||||
|
||||
func aesDecrypt(cipherText string, keyStr string, ivStr string) (string, error) {
|
||||
decode, err := base64.StdEncoding.DecodeString(cipherText)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := generateKey(keyStr)
|
||||
iv := generateIv(ivStr, keyStr)
|
||||
dst, err := openssl.AesCBCDecrypt(decode, key, iv, openssl.PKCS7_PADDING)
|
||||
return string(dst), err
|
||||
}
|
||||
|
||||
// ==================== 签名工具(与服务端 pkg/signature 一致)====================
|
||||
|
||||
func buildStringToSign(method, path, rawQuery string, body []byte, xAppId, timestamp, nonce string) string {
|
||||
canonical := canonicalQuery(rawQuery)
|
||||
bodyHash := sha256Hex(body)
|
||||
parts := []string{
|
||||
strings.ToUpper(method),
|
||||
path,
|
||||
canonical,
|
||||
bodyHash,
|
||||
xAppId,
|
||||
timestamp,
|
||||
nonce,
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func canonicalQuery(rawQuery string) string {
|
||||
if rawQuery == "" {
|
||||
return ""
|
||||
}
|
||||
pairs := strings.Split(rawQuery, "&")
|
||||
sort.Strings(pairs)
|
||||
return strings.Join(pairs, "&")
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return fmt.Sprintf("%x", h)
|
||||
}
|
||||
|
||||
func buildSignature(stringToSign, secret string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(stringToSign))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func signedRequest(method, url, rawQuery string, body []byte, token string) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", token) // 不带 Bearer 前缀,服务端直接 Parse token
|
||||
}
|
||||
|
||||
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
nonce := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
|
||||
// 提取 path
|
||||
path := req.URL.Path
|
||||
|
||||
sts := buildStringToSign(method, path, rawQuery, body, appId, timestamp, nonce)
|
||||
sig := buildSignature(sts, appSecret)
|
||||
|
||||
req.Header.Set("X-App-Id", appId)
|
||||
req.Header.Set("X-Timestamp", timestamp)
|
||||
req.Header.Set("X-Nonce", nonce)
|
||||
req.Header.Set("X-Signature", sig)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// ==================== 主逻辑 ====================
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 设备登录测试 ===")
|
||||
fmt.Printf("Server: %s\n", serverURL)
|
||||
fmt.Printf("Identifier: %s\n", identifier)
|
||||
fmt.Println()
|
||||
|
||||
// 1. 构造原始请求体
|
||||
payload := map[string]string{
|
||||
"identifier": identifier,
|
||||
"user_agent": userAgent,
|
||||
}
|
||||
plainBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
fmt.Printf("[ERROR] marshal payload: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("原始请求体: %s\n", string(plainBytes))
|
||||
|
||||
// 2. AES 加密请求体
|
||||
encData, nonce, err := aesEncrypt(plainBytes, securitySecret)
|
||||
if err != nil {
|
||||
fmt.Printf("[ERROR] encrypt: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
encBody := map[string]string{
|
||||
"data": encData,
|
||||
"time": nonce,
|
||||
}
|
||||
encBytes, _ := json.Marshal(encBody)
|
||||
fmt.Printf("加密请求体: %s\n\n", string(encBytes))
|
||||
|
||||
// 3. 发送请求
|
||||
req, err := signedRequest("POST", serverURL+"/v1/auth/login/device", "", encBytes, "")
|
||||
if err != nil {
|
||||
fmt.Printf("[ERROR] new request: %v\n", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Login-Type", "device")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
fmt.Printf("[ERROR] request failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
fmt.Printf("HTTP Status: %d\n", resp.StatusCode)
|
||||
fmt.Printf("原始响应: %s\n\n", string(respBody))
|
||||
|
||||
// 4. 解密响应
|
||||
// 响应格式: {"code":200,"data":{"data":"<encrypted>","time":"<nonce>"},"message":""}
|
||||
var outer struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &outer); err != nil {
|
||||
fmt.Printf("[ERROR] parse response: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if outer.Code != 200 {
|
||||
fmt.Printf("[FAIL] 登录失败: code=%d message=%s\n", outer.Code, outer.Message)
|
||||
return
|
||||
}
|
||||
|
||||
// data 字段是加密对象
|
||||
var encResp struct {
|
||||
Data string `json:"data"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
if err := json.Unmarshal(outer.Data, &encResp); err != nil {
|
||||
// 如果 Device.Enable=false,data 直接就是明文对象
|
||||
fmt.Printf("响应 data 非加密格式,直接解析: %s\n", string(outer.Data))
|
||||
var loginResp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err2 := json.Unmarshal(outer.Data, &loginResp); err2 == nil && loginResp.Token != "" {
|
||||
fmt.Printf("[OK] Token: %s\n", loginResp.Token)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
decrypted, err := aesDecrypt(encResp.Data, securitySecret, encResp.Time)
|
||||
if err != nil {
|
||||
fmt.Printf("[ERROR] decrypt response: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("解密后响应: %s\n\n", decrypted)
|
||||
|
||||
var loginResp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(decrypted), &loginResp); err != nil {
|
||||
fmt.Printf("[ERROR] parse decrypted: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("[OK] Token: %s\n", loginResp.Token)
|
||||
|
||||
// 5. 用 token 请求订阅列表
|
||||
fmt.Println("\n=== 请求订阅列表 ===")
|
||||
subReq, err := signedRequest("GET", serverURL+"/v1/public/subscribe/list", "", nil, loginResp.Token)
|
||||
if err != nil {
|
||||
fmt.Printf("[ERROR] build subscribe request: %v\n", err)
|
||||
return
|
||||
}
|
||||
subReq.Header.Set("Login-Type", "device")
|
||||
|
||||
subResp, err := client.Do(subReq)
|
||||
if err != nil {
|
||||
fmt.Printf("[ERROR] subscribe list request: %v\n", err)
|
||||
return
|
||||
}
|
||||
defer subResp.Body.Close()
|
||||
|
||||
subBody, _ := io.ReadAll(subResp.Body)
|
||||
fmt.Printf("HTTP Status: %d\n", subResp.StatusCode)
|
||||
fmt.Printf("原始响应: %s\n", string(subBody))
|
||||
|
||||
// 解密订阅列表响应
|
||||
var subOuter struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(subBody, &subOuter); err != nil {
|
||||
fmt.Printf("[ERROR] parse subscribe response: %v\n", err)
|
||||
return
|
||||
}
|
||||
if subOuter.Code != 200 {
|
||||
fmt.Printf("[FAIL] 订阅列表失败: code=%d message=%s\n", subOuter.Code, subOuter.Message)
|
||||
return
|
||||
}
|
||||
|
||||
var subEnc struct {
|
||||
Data string `json:"data"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
if err := json.Unmarshal(subOuter.Data, &subEnc); err != nil || subEnc.Data == "" {
|
||||
// 无加密,直接打印
|
||||
fmt.Printf("\n[OK] 订阅列表(明文): %s\n", string(subOuter.Data))
|
||||
return
|
||||
}
|
||||
subDecrypted, err := aesDecrypt(subEnc.Data, securitySecret, subEnc.Time)
|
||||
if err != nil {
|
||||
fmt.Printf("[ERROR] decrypt subscribe list: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("\n[OK] 订阅列表(解密): %s\n", subDecrypted)
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
//go:build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/initialize"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
authlogic "github.com/perfect-panel/server/internal/logic/auth"
|
||||
modelAuth "github.com/perfect-panel/server/internal/model/auth"
|
||||
modelLog "github.com/perfect-panel/server/internal/model/log"
|
||||
modelNode "github.com/perfect-panel/server/internal/model/node"
|
||||
modelSubscribe "github.com/perfect-panel/server/internal/model/subscribe"
|
||||
modelSystem "github.com/perfect-panel/server/internal/model/system"
|
||||
modelUser "github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/internal/types"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
"github.com/perfect-panel/server/pkg/orm"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var (
|
||||
configPath = flag.String("config", "etc/ppanel.yaml", "config file path on the test server")
|
||||
dsn = flag.String("dsn", "", "optional MySQL DSN override")
|
||||
identifier = flag.String("identifier", "", "optional device identifier; defaults to a unique test identifier")
|
||||
ip = flag.String("ip", "", "optional request IP; defaults to a reserved test IP")
|
||||
userAgent = flag.String("user-agent", "CodexDeviceTrialTest/1.0", "device user agent")
|
||||
write = flag.Bool("write", false, "actually create a test device user by running DeviceLogin")
|
||||
cleanup = flag.Bool("cleanup", false, "delete the test user/device/subscription/log rows after verification")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if !*write {
|
||||
fmt.Println("Refusing to write DB without -write.")
|
||||
fmt.Println("Example:")
|
||||
fmt.Printf(" go run scripts/test_device_trial_registration.go -config %s -write\n", *configPath)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
cfg := loadConfig(*configPath, *dsn)
|
||||
env := mustNewDeviceTrialEnv(ctx, cfg)
|
||||
defer env.close()
|
||||
|
||||
initialize.Device(env.svcCtx)
|
||||
initialize.Register(env.svcCtx)
|
||||
|
||||
if *identifier == "" {
|
||||
*identifier = fmt.Sprintf("codex-device-trial-%d", time.Now().UnixNano())
|
||||
}
|
||||
if *ip == "" {
|
||||
now := time.Now().UnixNano()
|
||||
*ip = fmt.Sprintf("198.18.%d.%d", now%200+1, now/200%200+1)
|
||||
}
|
||||
|
||||
fmt.Println("== device registration no-trial test ==")
|
||||
fmt.Printf("mysql: %s/%s\n", env.cfg.MySQL.Addr, env.cfg.MySQL.Dbname)
|
||||
fmt.Printf("redis: %s db=%d\n", env.cfg.Redis.Host, env.cfg.Redis.DB)
|
||||
fmt.Printf("device.enable=%v\n", env.svcCtx.Config.Device.Enable)
|
||||
fmt.Printf("register.enable_trial=%v trial_subscribe=%d trial_time=%d trial_time_unit=%s\n",
|
||||
env.svcCtx.Config.Register.EnableTrial,
|
||||
env.svcCtx.Config.Register.TrialSubscribe,
|
||||
env.svcCtx.Config.Register.TrialTime,
|
||||
env.svcCtx.Config.Register.TrialTimeUnit,
|
||||
)
|
||||
fmt.Printf("identifier=%s ip=%s user_agent=%s\n", *identifier, *ip, *userAgent)
|
||||
|
||||
if err := ensureIdentifierUnused(ctx, env.db, *identifier); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
|
||||
logic := authlogic.NewDeviceLoginLogic(ctx, env.svcCtx)
|
||||
resp, err := logic.DeviceLogin(&types.DeviceLoginRequest{
|
||||
Identifier: *identifier,
|
||||
IP: *ip,
|
||||
UserAgent: *userAgent,
|
||||
})
|
||||
if err != nil {
|
||||
fail(fmt.Errorf("DeviceLogin failed: %w", err))
|
||||
}
|
||||
if resp == nil || strings.TrimSpace(resp.Token) == "" {
|
||||
fail(fmt.Errorf("DeviceLogin returned empty token"))
|
||||
}
|
||||
fmt.Printf("login token: ok len=%d\n", len(resp.Token))
|
||||
|
||||
device, err := env.svcCtx.UserModel.FindOneDeviceByIdentifier(ctx, *identifier)
|
||||
if err != nil {
|
||||
fail(fmt.Errorf("query created device failed: %w", err))
|
||||
}
|
||||
fmt.Printf("device: id=%d sn=%s user_id=%d created_at=%s\n",
|
||||
device.Id,
|
||||
tool.DeviceIdToHash(device.Id),
|
||||
device.UserId,
|
||||
device.CreatedAt.Format(time.RFC3339),
|
||||
)
|
||||
|
||||
var subs []modelUser.Subscribe
|
||||
if err = env.db.WithContext(ctx).
|
||||
Where("user_id = ?", device.UserId).
|
||||
Order("id ASC").
|
||||
Find(&subs).Error; err != nil {
|
||||
fail(fmt.Errorf("query user_subscribe failed: %w", err))
|
||||
}
|
||||
for i := range subs {
|
||||
sub := &subs[i]
|
||||
fmt.Printf("subscribe: id=%d order_id=%d subscribe_id=%d status=%d start=%s expire=%s token_empty=%v\n",
|
||||
sub.Id,
|
||||
sub.OrderId,
|
||||
sub.SubscribeId,
|
||||
sub.Status,
|
||||
sub.StartTime.Format(time.RFC3339),
|
||||
sub.ExpireTime.Format(time.RFC3339),
|
||||
sub.Token == "",
|
||||
)
|
||||
if sub.OrderId == 0 &&
|
||||
sub.SubscribeId == env.svcCtx.Config.Register.TrialSubscribe &&
|
||||
(sub.Status == 0 || sub.Status == 1) &&
|
||||
sub.ExpireTime.After(time.Now()) {
|
||||
fail(fmt.Errorf("FAIL: device registration unexpectedly granted trial user_subscribe_id=%d user_id=%d", sub.Id, device.UserId))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("PASS: device registration created no active trial subscription for user_id=%d\n", device.UserId)
|
||||
|
||||
if *cleanup {
|
||||
if err = cleanupTestRows(ctx, env.db, device.UserId); err != nil {
|
||||
fail(fmt.Errorf("cleanup failed: %w", err))
|
||||
}
|
||||
fmt.Printf("cleanup: deleted test rows for user_id=%d\n", device.UserId)
|
||||
}
|
||||
}
|
||||
|
||||
type deviceTrialEnv struct {
|
||||
db *gorm.DB
|
||||
rds *redis.Client
|
||||
cfg config.Config
|
||||
svcCtx *svc.ServiceContext
|
||||
}
|
||||
|
||||
func mustNewDeviceTrialEnv(ctx context.Context, cfg config.Config) *deviceTrialEnv {
|
||||
db, err := orm.ConnectMysql(orm.Mysql{Config: cfg.MySQL})
|
||||
must(err)
|
||||
|
||||
rds := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Host,
|
||||
Password: cfg.Redis.Pass,
|
||||
DB: cfg.Redis.DB,
|
||||
PoolSize: cfg.Redis.PoolSize,
|
||||
MinIdleConns: cfg.Redis.MinIdleConns,
|
||||
})
|
||||
must(rds.Ping(ctx).Err())
|
||||
|
||||
svcCtx := &svc.ServiceContext{
|
||||
DB: db,
|
||||
Redis: rds,
|
||||
Config: cfg,
|
||||
AuthModel: modelAuth.NewModel(db, rds),
|
||||
LogModel: modelLog.NewModel(db),
|
||||
NodeModel: modelNode.NewModel(db, rds),
|
||||
SystemModel: modelSystem.NewModel(db, rds),
|
||||
UserModel: modelUser.NewModel(db, rds),
|
||||
SubscribeModel: modelSubscribe.NewModel(db, rds),
|
||||
}
|
||||
return &deviceTrialEnv{db: db, rds: rds, cfg: cfg, svcCtx: svcCtx}
|
||||
}
|
||||
|
||||
func (e *deviceTrialEnv) close() {
|
||||
if e == nil || e.rds == nil {
|
||||
return
|
||||
}
|
||||
_ = e.rds.Close()
|
||||
}
|
||||
|
||||
func loadConfig(path, dsn string) config.Config {
|
||||
var cfg config.Config
|
||||
conf.MustLoad(path, &cfg)
|
||||
if dsn != "" {
|
||||
parsed := orm.ParseDSN(dsn)
|
||||
if parsed == nil {
|
||||
fail(fmt.Errorf("invalid dsn"))
|
||||
}
|
||||
cfg.MySQL = *parsed
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func ensureIdentifierUnused(ctx context.Context, db *gorm.DB, identifier string) error {
|
||||
var count int64
|
||||
if err := db.WithContext(ctx).
|
||||
Model(&modelUser.Device{}).
|
||||
Where("identifier = ?", identifier).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return fmt.Errorf("identifier already exists: %s", identifier)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupTestRows(ctx context.Context, db *gorm.DB, userID int64) error {
|
||||
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("object_id = ?", userID).Delete(&modelLog.SystemLog{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&modelUser.Subscribe{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&modelUser.AuthMethods{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("user_id = ?", userID).Delete(&modelUser.Device{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", userID).Delete(&modelUser.User{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
func must(err error) {
|
||||
if err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -1,485 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
const inviteGiftMarker = "codex-test-invite-gift-days"
|
||||
|
||||
type giftLog struct {
|
||||
Type uint16 `json:"type"`
|
||||
OrderNo string `json:"order_no"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Balance int64 `json:"balance"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type commissionLog struct {
|
||||
Type uint16 `json:"type"`
|
||||
Amount int64 `json:"amount"`
|
||||
OrderNo string `json:"order_no"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type userSubscribe struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
ExpireTime time.Time
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
dsn = flag.String("dsn", "", "MySQL DSN, for example root:pass@tcp(host:3306)/ppanel?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai")
|
||||
writeDB = flag.Bool("write-db", false, "create isolated rows, simulate invite gifts, and clean them up")
|
||||
keep = flag.Bool("keep", false, "keep rows for manual inspection")
|
||||
cleanupOnly = flag.Bool("cleanup-only", false, "delete leftover rows created by this script and exit")
|
||||
giftDays = flag.Int("gift-days", 3, "days to add to both invite users")
|
||||
commission = flag.Int64("commission-percent", 10, "commission percent for commission-path simulation")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *dsn == "" {
|
||||
exitf("-dsn is required")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := sql.Open("mysql", *dsn)
|
||||
mustNoErr(err)
|
||||
defer db.Close()
|
||||
db.SetMaxIdleConns(1)
|
||||
db.SetMaxOpenConns(1)
|
||||
mustNoErr(db.PingContext(ctx))
|
||||
|
||||
if *cleanupOnly {
|
||||
mustNoErr(cleanup(ctx, db))
|
||||
fmt.Println("cleanup done")
|
||||
return
|
||||
}
|
||||
|
||||
if !*writeDB {
|
||||
fmt.Println("dry run only. Add -write-db to create isolated invite rows in the TEST database.")
|
||||
return
|
||||
}
|
||||
if *giftDays <= 0 {
|
||||
exitf("-gift-days must be positive")
|
||||
}
|
||||
|
||||
mustNoErr(cleanup(ctx, db))
|
||||
if !*keep {
|
||||
defer func() {
|
||||
if err := cleanup(context.Background(), db); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cleanup failed: %v\n", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
planID := mustCreatePlan(ctx, db)
|
||||
runSelfInviteScenario(ctx, db, planID, *giftDays)
|
||||
runFamilyInviteScenario(ctx, db, planID, *giftDays)
|
||||
runCommissionScenario(ctx, db, planID, *giftDays, *commission)
|
||||
|
||||
if *keep {
|
||||
fmt.Println("rows kept; cleanup with -cleanup-only. inviteGiftMarker:", inviteGiftMarker)
|
||||
}
|
||||
}
|
||||
|
||||
func runSelfInviteScenario(ctx context.Context, db *sql.DB, planID int64, giftDays int) {
|
||||
refererID := mustCreateUser(ctx, db, "self-referer", 0)
|
||||
refereeID := mustCreateUser(ctx, db, "self-referee", refererID)
|
||||
|
||||
baseExpire := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Second)
|
||||
refererSubID := mustCreateUserSubscribe(ctx, db, refererID, planID, baseExpire)
|
||||
refereeSubID := mustCreateUserSubscribe(ctx, db, refereeID, planID, baseExpire)
|
||||
|
||||
orderNo := fmt.Sprintf("%s-self-order-%d", inviteGiftMarker, time.Now().UnixNano())
|
||||
mustNoErr(simulateInviteGiftBoth(ctx, db, orderNo, refererID, refereeID, 0, giftDays))
|
||||
mustNoErr(simulateInviteGiftBoth(ctx, db, orderNo, refererID, refereeID, 0, giftDays))
|
||||
|
||||
assertExpire(ctx, db, "referer", refererSubID, baseExpire, giftDays)
|
||||
assertExpire(ctx, db, "referee", refereeSubID, baseExpire, giftDays)
|
||||
|
||||
logs := mustGiftLogCount(ctx, db, orderNo)
|
||||
if logs != 2 {
|
||||
exitf("gift log count mismatch after duplicate simulation: got=%d want=2", logs)
|
||||
}
|
||||
|
||||
fmt.Printf("PASS self invite: referer=%d referee=%d order=%s gift_days=%d logs=%d\n", refererID, refereeID, orderNo, giftDays, logs)
|
||||
}
|
||||
|
||||
func runFamilyInviteScenario(ctx context.Context, db *sql.DB, planID int64, giftDays int) {
|
||||
refererOwnerID := mustCreateUser(ctx, db, "family-referer-owner", 0)
|
||||
refererMemberID := mustCreateUser(ctx, db, "family-referer-member", 0)
|
||||
refereeOwnerID := mustCreateUser(ctx, db, "family-referee-owner", 0)
|
||||
refereeMemberID := mustCreateUser(ctx, db, "family-referee-member", refererMemberID)
|
||||
mustCreateFamily(ctx, db, refererOwnerID, refererMemberID)
|
||||
mustCreateFamily(ctx, db, refereeOwnerID, refereeMemberID)
|
||||
|
||||
baseExpire := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Second)
|
||||
refererOwnerSubID := mustCreateUserSubscribe(ctx, db, refererOwnerID, planID, baseExpire)
|
||||
refereeOwnerSubID := mustCreateUserSubscribe(ctx, db, refereeOwnerID, planID, baseExpire)
|
||||
refererMemberSubID := mustCreateUserSubscribe(ctx, db, refererMemberID, planID, baseExpire)
|
||||
refereeMemberSubID := mustCreateUserSubscribe(ctx, db, refereeMemberID, planID, baseExpire)
|
||||
|
||||
orderNo := fmt.Sprintf("%s-family-order-%d", inviteGiftMarker, time.Now().UnixNano())
|
||||
mustNoErr(simulateInviteGiftBoth(ctx, db, orderNo, refererMemberID, refereeMemberID, refereeOwnerID, giftDays))
|
||||
mustNoErr(simulateInviteGiftBoth(ctx, db, orderNo, refererMemberID, refereeMemberID, refereeOwnerID, giftDays))
|
||||
|
||||
assertExpire(ctx, db, "referer owner", refererOwnerSubID, baseExpire, giftDays)
|
||||
assertExpire(ctx, db, "referee owner", refereeOwnerSubID, baseExpire, giftDays)
|
||||
assertExpire(ctx, db, "referer member", refererMemberSubID, baseExpire, 0)
|
||||
assertExpire(ctx, db, "referee member", refereeMemberSubID, baseExpire, 0)
|
||||
|
||||
logs := mustGiftLogCount(ctx, db, orderNo)
|
||||
if logs != 2 {
|
||||
exitf("family gift log count mismatch after duplicate simulation: got=%d want=2", logs)
|
||||
}
|
||||
fmt.Printf("PASS family invite: referer_member=%d->owner=%d referee_member=%d->owner=%d order=%s gift_days=%d logs=%d\n",
|
||||
refererMemberID, refererOwnerID, refereeMemberID, refereeOwnerID, orderNo, giftDays, logs)
|
||||
}
|
||||
|
||||
func runCommissionScenario(ctx context.Context, db *sql.DB, planID int64, giftDays int, commissionPercent int64) {
|
||||
if commissionPercent <= 0 {
|
||||
fmt.Println("SKIP commission invite: commission-percent <= 0")
|
||||
return
|
||||
}
|
||||
const amount int64 = 599
|
||||
|
||||
refererID := mustCreateUser(ctx, db, "commission-referer", 0)
|
||||
refereeID := mustCreateUser(ctx, db, "commission-referee", refererID)
|
||||
baseExpire := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Second)
|
||||
refererSubID := mustCreateUserSubscribe(ctx, db, refererID, planID, baseExpire)
|
||||
refereeSubID := mustCreateUserSubscribe(ctx, db, refereeID, planID, baseExpire)
|
||||
|
||||
orderNo := fmt.Sprintf("%s-commission-first-order-%d", inviteGiftMarker, time.Now().UnixNano())
|
||||
mustNoErr(simulateInviteCommission(ctx, db, orderNo, refererID, refereeID, 0, giftDays, amount, commissionPercent, true))
|
||||
mustNoErr(simulateInviteCommission(ctx, db, orderNo, refererID, refereeID, 0, giftDays, amount, commissionPercent, true))
|
||||
|
||||
wantCommission := amount * commissionPercent / 100
|
||||
assertExpire(ctx, db, "commission referer", refererSubID, baseExpire, 0)
|
||||
assertExpire(ctx, db, "commission referee", refereeSubID, baseExpire, giftDays)
|
||||
assertCommission(ctx, db, refererID, wantCommission)
|
||||
assertLogCount(ctx, db, "commission first gift", 34, orderNo, 1)
|
||||
assertLogCount(ctx, db, "commission first commission", 33, orderNo, 1)
|
||||
|
||||
nonFirstRefererID := mustCreateUser(ctx, db, "commission-nonfirst-referer", 0)
|
||||
nonFirstRefereeID := mustCreateUser(ctx, db, "commission-nonfirst-referee", nonFirstRefererID)
|
||||
nonFirstRefererSubID := mustCreateUserSubscribe(ctx, db, nonFirstRefererID, planID, baseExpire)
|
||||
nonFirstRefereeSubID := mustCreateUserSubscribe(ctx, db, nonFirstRefereeID, planID, baseExpire)
|
||||
nonFirstOrderNo := fmt.Sprintf("%s-commission-nonfirst-order-%d", inviteGiftMarker, time.Now().UnixNano())
|
||||
mustNoErr(simulateInviteCommission(ctx, db, nonFirstOrderNo, nonFirstRefererID, nonFirstRefereeID, 0, giftDays, amount, commissionPercent, false))
|
||||
mustNoErr(simulateInviteCommission(ctx, db, nonFirstOrderNo, nonFirstRefererID, nonFirstRefereeID, 0, giftDays, amount, commissionPercent, false))
|
||||
|
||||
assertExpire(ctx, db, "commission non-first referer", nonFirstRefererSubID, baseExpire, 0)
|
||||
assertExpire(ctx, db, "commission non-first referee", nonFirstRefereeSubID, baseExpire, 0)
|
||||
assertCommission(ctx, db, nonFirstRefererID, wantCommission)
|
||||
assertLogCount(ctx, db, "commission non-first gift", 34, nonFirstOrderNo, 0)
|
||||
assertLogCount(ctx, db, "commission non-first commission", 33, nonFirstOrderNo, 1)
|
||||
|
||||
fmt.Printf("PASS commission invite: percent=%d first_order_commission=%d non_first_commission=%d\n",
|
||||
commissionPercent, wantCommission, wantCommission)
|
||||
}
|
||||
|
||||
func assertExpire(ctx context.Context, db *sql.DB, label string, subID int64, before time.Time, addedDays int) {
|
||||
got := mustExpire(ctx, db, subID)
|
||||
want := before.Add(time.Duration(addedDays) * 24 * time.Hour)
|
||||
if !got.Equal(want) {
|
||||
exitf("%s expire mismatch: got=%s want=%s", label, got, want)
|
||||
}
|
||||
fmt.Printf("PASS %s subscribe=%d expire %s -> %s\n", label, subID, before.Format(time.RFC3339), got.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func simulateInviteGiftBoth(ctx context.Context, db *sql.DB, orderNo string, refererID, refereeID, forcedRefereeOwnerID int64, days int) error {
|
||||
refereeTargetID, err := resolveGiftTargetUser(ctx, db, refereeID, forcedRefereeOwnerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve referee gift target: %w", err)
|
||||
}
|
||||
refererTargetID, err := resolveGiftTargetUser(ctx, db, refererID, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve referer gift target: %w", err)
|
||||
}
|
||||
if err := grantGiftDays(ctx, db, refereeTargetID, orderNo, days); err != nil {
|
||||
return fmt.Errorf("grant referee gift: %w", err)
|
||||
}
|
||||
if err := grantGiftDays(ctx, db, refererTargetID, orderNo, days); err != nil {
|
||||
return fmt.Errorf("grant referer gift: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func simulateInviteCommission(ctx context.Context, db *sql.DB, orderNo string, refererID, refereeID, forcedRefereeOwnerID int64, days int, amount int64, commissionPercent int64, isFirstOrder bool) error {
|
||||
if err := grantCommission(ctx, db, refererID, orderNo, amount, commissionPercent); err != nil {
|
||||
return fmt.Errorf("grant commission: %w", err)
|
||||
}
|
||||
if isFirstOrder {
|
||||
refereeTargetID, err := resolveGiftTargetUser(ctx, db, refereeID, forcedRefereeOwnerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve referee gift target: %w", err)
|
||||
}
|
||||
if err := grantGiftDays(ctx, db, refereeTargetID, orderNo, days); err != nil {
|
||||
return fmt.Errorf("grant commission-path referee gift: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveGiftTargetUser(ctx context.Context, db *sql.DB, userID int64, forcedOwnerID int64) (int64, error) {
|
||||
if forcedOwnerID > 0 {
|
||||
return forcedOwnerID, nil
|
||||
}
|
||||
var ownerID int64
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT uf.owner_user_id
|
||||
FROM user_family_member ufm
|
||||
JOIN user_family uf ON uf.id = ufm.family_id AND uf.deleted_at IS NULL
|
||||
WHERE ufm.user_id = ?
|
||||
AND ufm.deleted_at IS NULL
|
||||
AND ufm.status = 1
|
||||
AND ufm.role = 2
|
||||
AND uf.status = 1
|
||||
ORDER BY ufm.role
|
||||
LIMIT 1`, userID).Scan(&ownerID)
|
||||
if err == sql.ErrNoRows {
|
||||
return userID, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if ownerID > 0 && ownerID != userID {
|
||||
return ownerID, nil
|
||||
}
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
func grantCommission(ctx context.Context, db *sql.DB, refererID int64, orderNo string, amount int64, commissionPercent int64) error {
|
||||
var existing int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM system_logs WHERE type = 33 AND object_id = ? AND content LIKE ?",
|
||||
refererID, "%\""+orderNo+"\"%",
|
||||
).Scan(&existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
commissionAmount := amount * commissionPercent / 100
|
||||
if _, err = db.ExecContext(ctx,
|
||||
"UPDATE `user` SET commission = commission + ?, updated_at = ? WHERE id = ?",
|
||||
commissionAmount, time.Now(), refererID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := json.Marshal(commissionLog{
|
||||
Type: 331,
|
||||
Amount: commissionAmount,
|
||||
OrderNo: orderNo,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.ExecContext(ctx,
|
||||
"INSERT INTO system_logs (`type`, object_id, content, created_at, `date`) VALUES (33, ?, ?, ?, ?)",
|
||||
refererID, string(content), time.Now(), time.Now().Format("2006-01-02"),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func grantGiftDays(ctx context.Context, db *sql.DB, userID int64, orderNo string, days int) error {
|
||||
var existing int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM system_logs WHERE type = 34 AND object_id = ? AND content LIKE ?",
|
||||
userID, "%\""+orderNo+"\"%",
|
||||
).Scan(&existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sub, err := findActiveSubscribe(ctx, db, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nextExpire := sub.ExpireTime
|
||||
if !sub.ExpireTime.Equal(time.UnixMilli(0)) {
|
||||
nextExpire = sub.ExpireTime.Add(time.Duration(days) * 24 * time.Hour)
|
||||
if _, err = db.ExecContext(ctx,
|
||||
"UPDATE user_subscribe SET expire_time = ?, updated_at = ? WHERE id = ?",
|
||||
nextExpire, time.Now(), sub.ID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
content, err := json.Marshal(giftLog{
|
||||
Type: 341,
|
||||
OrderNo: orderNo,
|
||||
SubscribeId: sub.ID,
|
||||
Amount: int64(days),
|
||||
Balance: 0,
|
||||
Remark: "邀请赠送",
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.ExecContext(ctx,
|
||||
"INSERT INTO system_logs (`type`, object_id, content, created_at, `date`) VALUES (34, ?, ?, ?, ?)",
|
||||
userID, string(content), time.Now(), time.Now().Format("2006-01-02"),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func findActiveSubscribe(ctx context.Context, db *sql.DB, userID int64) (*userSubscribe, error) {
|
||||
var row userSubscribe
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT id, user_id, expire_time
|
||||
FROM user_subscribe
|
||||
WHERE user_id = ?
|
||||
AND status IN (0, 1)
|
||||
AND (expire_time > ? OR expire_time = '1970-01-01 08:00:00')
|
||||
ORDER BY expire_time DESC, id DESC
|
||||
LIMIT 1`, userID, time.Now()).Scan(&row.ID, &row.UserID, &row.ExpireTime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func mustCreatePlan(ctx context.Context, db *sql.DB) int64 {
|
||||
var sort int64
|
||||
mustNoErr(db.QueryRowContext(ctx, "SELECT COALESCE(MAX(sort), 0) + 1 FROM subscribe").Scan(&sort))
|
||||
res, err := db.ExecContext(ctx, `
|
||||
INSERT INTO subscribe
|
||||
(name, language, description, unit_price, unit_time, discount, replacement, inventory, traffic, speed_limit, device_limit, quota, new_user_only, nodes, node_tags, node_group_ids, node_group_id, traffic_limit, `+"`show`"+`, sell, sort, deduction_ratio, allow_deduction, reset_cycle, renewal_reset, show_original_price, created_at, updated_at)
|
||||
VALUES (?, 'en', '', 599, 'Month', '', 0, -1, 1073741824, 0, 0, 0, false, '', '', '[]', 0, '', false, false, ?, 0, true, 0, false, true, ?, ?)`,
|
||||
inviteGiftMarker+"-plan", sort, time.Now(), time.Now())
|
||||
mustNoErr(err)
|
||||
id, err := res.LastInsertId()
|
||||
mustNoErr(err)
|
||||
return id
|
||||
}
|
||||
|
||||
func mustCreateUser(ctx context.Context, db *sql.DB, role string, refererID int64) int64 {
|
||||
res, err := db.ExecContext(ctx, `
|
||||
INSERT INTO `+"`user`"+`
|
||||
(password, algo, 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, salt)
|
||||
VALUES (?, 'default', '', 0, '', ?, 0, 0, true, 0, true, false, true, true, true, true, '', '', ?, ?, ?, 'default')`,
|
||||
inviteGiftMarker, refererID, inviteGiftMarker+"-"+role, time.Now(), time.Now())
|
||||
mustNoErr(err)
|
||||
id, err := res.LastInsertId()
|
||||
mustNoErr(err)
|
||||
_, err = db.ExecContext(ctx, "UPDATE `user` SET refer_code = ?, updated_at = ? WHERE id = ?", fmt.Sprintf("codex%d", id), time.Now(), id)
|
||||
mustNoErr(err)
|
||||
return id
|
||||
}
|
||||
|
||||
func mustCreateFamily(ctx context.Context, db *sql.DB, ownerID, memberID int64) int64 {
|
||||
res, err := db.ExecContext(ctx, `
|
||||
INSERT INTO user_family
|
||||
(owner_user_id, max_members, status, created_at, updated_at)
|
||||
VALUES (?, 3, 1, ?, ?)`, ownerID, time.Now(), time.Now())
|
||||
mustNoErr(err)
|
||||
familyID, err := res.LastInsertId()
|
||||
mustNoErr(err)
|
||||
|
||||
now := time.Now()
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO user_family_member
|
||||
(family_id, user_id, role, status, join_source, joined_at, created_at, updated_at)
|
||||
VALUES
|
||||
(?, ?, 1, 1, ?, ?, ?, ?),
|
||||
(?, ?, 2, 1, ?, ?, ?, ?)`,
|
||||
familyID, ownerID, inviteGiftMarker, now, now, now,
|
||||
familyID, memberID, inviteGiftMarker, now, now, now)
|
||||
mustNoErr(err)
|
||||
return familyID
|
||||
}
|
||||
|
||||
func mustCreateUserSubscribe(ctx context.Context, db *sql.DB, userID, planID int64, expire time.Time) int64 {
|
||||
token := fmt.Sprintf("%s-token-%d-%d", inviteGiftMarker, userID, time.Now().UnixNano())
|
||||
uuid := fmt.Sprintf("%08d-0000-4000-8000-%012d", userID, time.Now().UnixNano()%1_000_000_000_000)
|
||||
res, err := db.ExecContext(ctx, `
|
||||
INSERT INTO user_subscribe
|
||||
(user_id, order_id, subscribe_id, node_group_id, group_locked, traffic, download, upload, expired_download, expired_upload, token, uuid, status, note, created_at, updated_at, start_time, expire_time)
|
||||
VALUES (?, 0, ?, 0, false, 1073741824, 0, 0, 0, 0, ?, ?, 1, ?, ?, ?, ?, ?)`,
|
||||
userID, planID, token, uuid, inviteGiftMarker, time.Now(), time.Now(), time.Now().Add(-time.Hour), expire)
|
||||
mustNoErr(err)
|
||||
id, err := res.LastInsertId()
|
||||
mustNoErr(err)
|
||||
return id
|
||||
}
|
||||
|
||||
func mustExpire(ctx context.Context, db *sql.DB, subID int64) time.Time {
|
||||
var expire time.Time
|
||||
mustNoErr(db.QueryRowContext(ctx, "SELECT expire_time FROM user_subscribe WHERE id = ?", subID).Scan(&expire))
|
||||
return expire
|
||||
}
|
||||
|
||||
func mustGiftLogCount(ctx context.Context, db *sql.DB, orderNo string) int64 {
|
||||
var count int64
|
||||
mustNoErr(db.QueryRowContext(ctx, "SELECT COUNT(*) FROM system_logs WHERE type = 34 AND content LIKE ?", "%"+orderNo+"%").Scan(&count))
|
||||
return count
|
||||
}
|
||||
|
||||
func assertCommission(ctx context.Context, db *sql.DB, userID int64, want int64) {
|
||||
var got int64
|
||||
mustNoErr(db.QueryRowContext(ctx, "SELECT commission FROM `user` WHERE id = ?", userID).Scan(&got))
|
||||
if got != want {
|
||||
exitf("commission mismatch: user=%d got=%d want=%d", userID, got, want)
|
||||
}
|
||||
fmt.Printf("PASS commission user=%d amount=%d\n", userID, got)
|
||||
}
|
||||
|
||||
func assertLogCount(ctx context.Context, db *sql.DB, label string, logType uint8, orderNo string, want int64) {
|
||||
var got int64
|
||||
mustNoErr(db.QueryRowContext(ctx, "SELECT COUNT(*) FROM system_logs WHERE type = ? AND content LIKE ?", logType, "%"+orderNo+"%").Scan(&got))
|
||||
if got != want {
|
||||
exitf("%s log count mismatch: got=%d want=%d", label, got, want)
|
||||
}
|
||||
fmt.Printf("PASS %s logs=%d\n", label, got)
|
||||
}
|
||||
|
||||
func cleanup(ctx context.Context, db *sql.DB) error {
|
||||
stmts := []string{
|
||||
"DELETE FROM user_family_member WHERE join_source = '" + inviteGiftMarker + "'",
|
||||
"DELETE FROM user_family WHERE owner_user_id IN (SELECT id FROM `user` WHERE remark LIKE '" + inviteGiftMarker + "%')",
|
||||
"DELETE FROM system_logs WHERE type IN (33, 34) AND content LIKE '%" + inviteGiftMarker + "%'",
|
||||
"DELETE FROM user_subscribe WHERE note = '" + inviteGiftMarker + "' OR token LIKE '" + inviteGiftMarker + "%'",
|
||||
"DELETE FROM subscribe WHERE name LIKE '" + inviteGiftMarker + "%'",
|
||||
"DELETE FROM `user` WHERE remark LIKE '" + inviteGiftMarker + "%'",
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := db.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("%s: %w", stmt, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustNoErr(err error) {
|
||||
if err != nil {
|
||||
exitf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func exitf(format string, args ...interface{}) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
fmt.Fprintln(os.Stderr, "FAIL:", strings.TrimSpace(msg))
|
||||
os.Exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user