重置项目上下文与规范文档
This commit is contained in:
Binary file not shown.
@@ -1,127 +0,0 @@
|
||||
"""批量回收设备脚本测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).with_name("batch_device_recall.py")
|
||||
SPEC = importlib.util.spec_from_file_location("batch_device_recall", SCRIPT_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
batch_device_recall = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = batch_device_recall
|
||||
SPEC.loader.exec_module(batch_device_recall)
|
||||
|
||||
|
||||
class LoadDevicesTest(unittest.TestCase):
|
||||
"""验证单列 CSV 输入。"""
|
||||
|
||||
def write_csv(self, content: str) -> Path:
|
||||
"""创建临时 CSV。"""
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
path = Path(directory.name) / "devices.csv"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def test_loads_header_and_rows(self) -> None:
|
||||
"""支持表头并保留行号。"""
|
||||
path = self.write_csv("device_identifier\nIMEI001\nVIRTUAL001\n")
|
||||
|
||||
devices = batch_device_recall.load_devices(path)
|
||||
|
||||
self.assertEqual(
|
||||
devices,
|
||||
[
|
||||
batch_device_recall.DeviceInput(2, "IMEI001"),
|
||||
batch_device_recall.DeviceInput(3, "VIRTUAL001"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_rejects_duplicate_identifier(self) -> None:
|
||||
"""同一标识不能重复回收。"""
|
||||
path = self.write_csv("IMEI001\nIMEI001\n")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "重复"):
|
||||
batch_device_recall.load_devices(path)
|
||||
|
||||
|
||||
class ExactDeviceMatchesTest(unittest.TestCase):
|
||||
"""验证模糊查询结果必须再次精确匹配。"""
|
||||
|
||||
def test_keeps_only_exact_virtual_no_or_imei(self) -> None:
|
||||
"""排除仅包含关键字的候选设备。"""
|
||||
body = {
|
||||
"data": {
|
||||
"items": [
|
||||
{"id": 1, "virtual_no": "VIRTUAL001", "imei": "IMEI001"},
|
||||
{"id": 2, "virtual_no": "VIRTUAL001-X", "imei": "IMEI002"},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
matches = batch_device_recall.exact_device_matches(body, "VIRTUAL001")
|
||||
|
||||
self.assertEqual([item["id"] for item in matches], [1])
|
||||
|
||||
|
||||
class RecallBatchRowsTest(unittest.TestCase):
|
||||
"""验证批量接口结果映射。"""
|
||||
|
||||
def test_maps_failed_items_to_source_rows(self) -> None:
|
||||
"""接口失败明细应对应回原 CSV 标识。"""
|
||||
devices = [
|
||||
batch_device_recall.ResolvedDevice(
|
||||
batch_device_recall.DeviceInput(2, "IMEI001"), 1, "V001", "IMEI001"
|
||||
),
|
||||
batch_device_recall.ResolvedDevice(
|
||||
batch_device_recall.DeviceInput(3, "IMEI002"), 2, "V002", "IMEI002"
|
||||
),
|
||||
]
|
||||
result = batch_device_recall.HTTPResult(
|
||||
200,
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"success_count": 1,
|
||||
"fail_count": 1,
|
||||
"failed_items": [{"device_id": 2, "reason": "设备已在平台库存中"}],
|
||||
},
|
||||
},
|
||||
"",
|
||||
)
|
||||
|
||||
rows, success = batch_device_recall.recall_batch_rows(devices, result)
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertEqual([row["status"] for row in rows], ["成功", "失败"])
|
||||
self.assertEqual(rows[1]["msg"], "设备已在平台库存中")
|
||||
|
||||
def test_rejects_incomplete_failed_items(self) -> None:
|
||||
"""失败数量没有对应明细时不能把设备误记为成功。"""
|
||||
device = batch_device_recall.ResolvedDevice(
|
||||
batch_device_recall.DeviceInput(2, "IMEI001"), 1, "V001", "IMEI001"
|
||||
)
|
||||
result = batch_device_recall.HTTPResult(
|
||||
200,
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {"success_count": 0, "fail_count": 1, "failed_items": []},
|
||||
},
|
||||
"",
|
||||
)
|
||||
|
||||
rows, success = batch_device_recall.recall_batch_rows([device], result)
|
||||
|
||||
self.assertFalse(success)
|
||||
self.assertEqual(rows[0]["status"], "失败")
|
||||
self.assertIn("统计", rows[0]["msg"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,172 +0,0 @@
|
||||
"""批量换货脚本的输入校验测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).with_name("batch_exchange.py")
|
||||
SPEC = importlib.util.spec_from_file_location("batch_exchange", SCRIPT_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
batch_exchange = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = batch_exchange
|
||||
SPEC.loader.exec_module(batch_exchange)
|
||||
|
||||
|
||||
class LoadExchangesTest(unittest.TestCase):
|
||||
"""验证 CSV 映射校验不会把危险批次交给接口执行。"""
|
||||
|
||||
def write_csv(self, content: str) -> Path:
|
||||
"""在临时目录中创建待解析的 CSV。"""
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(directory.cleanup)
|
||||
path = Path(directory.name) / "exchanges.csv"
|
||||
path.write_text(content, encoding="utf-8")
|
||||
return path
|
||||
|
||||
def test_loads_supported_header_and_rows(self) -> None:
|
||||
"""支持标准表头并保留原始行号。"""
|
||||
path = self.write_csv("old_identifier,new_identifier\nold-1,new-1\nold-2,new-2\n")
|
||||
|
||||
exchanges = batch_exchange.load_exchanges(path)
|
||||
|
||||
self.assertEqual(
|
||||
exchanges,
|
||||
[
|
||||
batch_exchange.ExchangeInput(2, "old-1", "new-1"),
|
||||
batch_exchange.ExchangeInput(3, "old-2", "new-2"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_rejects_duplicate_old_identifier(self) -> None:
|
||||
"""同一旧资产不能在一批中换出两次。"""
|
||||
path = self.write_csv("old-1,new-1\nold-1,new-2\n")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "旧资产.*重复"):
|
||||
batch_exchange.load_exchanges(path)
|
||||
|
||||
def test_rejects_duplicate_new_identifier(self) -> None:
|
||||
"""同一新资产不能接收两次换货。"""
|
||||
path = self.write_csv("old-1,new-1\nold-2,new-1\n")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "新资产.*重复"):
|
||||
batch_exchange.load_exchanges(path)
|
||||
|
||||
def test_rejects_same_identifier_in_one_row(self) -> None:
|
||||
"""禁止资产换给自身。"""
|
||||
path = self.write_csv("old-1,old-1\n")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "新旧资产标识相同"):
|
||||
batch_exchange.load_exchanges(path)
|
||||
|
||||
def test_rejects_cross_row_asset_reuse(self) -> None:
|
||||
"""禁止资产在同批中同时作为旧资产和新资产。"""
|
||||
path = self.write_csv("old-1,new-1\nnew-1,new-2\n")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "同时作为.*旧资产.*新资产"):
|
||||
batch_exchange.load_exchanges(path)
|
||||
|
||||
def test_rejects_missing_column_value(self) -> None:
|
||||
"""双列中的空值必须在请求前被拦截。"""
|
||||
path = self.write_csv("old_identifier,new_identifier\nold-1,\n")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "均不能为空"):
|
||||
batch_exchange.load_exchanges(path)
|
||||
|
||||
|
||||
class BuildExchangePayloadTest(unittest.TestCase):
|
||||
"""验证脚本不能关闭直接换货或数据迁移。"""
|
||||
|
||||
def test_forces_direct_flow_and_migration(self) -> None:
|
||||
"""请求固定为 direct 且 migrate_data 为 true。"""
|
||||
exchange = batch_exchange.ExchangeInput(2, "old-1", "new-1")
|
||||
|
||||
payload = batch_exchange.build_exchange_payload(
|
||||
exchange,
|
||||
"iot_card",
|
||||
"批量直接换货",
|
||||
None,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["flow_type"], "direct")
|
||||
self.assertIs(payload["migrate_data"], True)
|
||||
self.assertEqual(payload["old_asset_type"], "iot_card")
|
||||
|
||||
|
||||
class ExchangeAssetTest(unittest.TestCase):
|
||||
"""验证结果必须同时满足接口成功和迁移完成。"""
|
||||
|
||||
class FakeClient:
|
||||
"""返回指定响应的换货客户端替身。"""
|
||||
|
||||
def __init__(self, result: object) -> None:
|
||||
self.result = result
|
||||
|
||||
def create_exchange(self, *args: object) -> object:
|
||||
"""返回测试预设的接口响应。"""
|
||||
return self.result
|
||||
|
||||
def test_marks_completed_migration_as_success(self) -> None:
|
||||
"""业务成功且迁移完成时结果为成功。"""
|
||||
result = batch_exchange.HTTPResult(
|
||||
status=200,
|
||||
body={
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": 123,
|
||||
"exchange_no": "EX123",
|
||||
"migration_completed": True,
|
||||
"migration_balance": 100,
|
||||
},
|
||||
},
|
||||
raw_body="",
|
||||
)
|
||||
|
||||
outcome = batch_exchange.exchange_asset(
|
||||
self.FakeClient(result),
|
||||
"token",
|
||||
batch_exchange.ExchangeInput(2, "old-1", "new-1"),
|
||||
"iot_card",
|
||||
"批量直接换货",
|
||||
None,
|
||||
)
|
||||
|
||||
self.assertTrue(outcome.success)
|
||||
self.assertEqual(outcome.exchange_no, "EX123")
|
||||
|
||||
def test_rejects_success_response_without_completed_migration(self) -> None:
|
||||
"""业务成功但未确认迁移完成时结果仍为失败。"""
|
||||
result = batch_exchange.HTTPResult(
|
||||
status=200,
|
||||
body={
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": 123,
|
||||
"exchange_no": "EX123",
|
||||
"migration_completed": False,
|
||||
"migration_balance": 0,
|
||||
},
|
||||
},
|
||||
raw_body="",
|
||||
)
|
||||
|
||||
outcome = batch_exchange.exchange_asset(
|
||||
self.FakeClient(result),
|
||||
"token",
|
||||
batch_exchange.ExchangeInput(2, "old-1", "new-1"),
|
||||
"iot_card",
|
||||
"批量直接换货",
|
||||
None,
|
||||
)
|
||||
|
||||
self.assertFalse(outcome.success)
|
||||
self.assertIn("未确认数据迁移完成", outcome.message)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,110 +0,0 @@
|
||||
"""批量修改套餐过期时间脚本测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
SCRIPT_PATH = Path(__file__).with_name("batch_update_package_expiry.py")
|
||||
sys.path.insert(0, str(SCRIPT_PATH.parent))
|
||||
SPEC = importlib.util.spec_from_file_location("batch_update_package_expiry", SCRIPT_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
batch_update_package_expiry = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = batch_update_package_expiry
|
||||
SPEC.loader.exec_module(batch_update_package_expiry)
|
||||
sys.path.pop(0)
|
||||
|
||||
|
||||
class BatchUpdatePackageExpiryTest(unittest.TestCase):
|
||||
"""验证过期时间与生效套餐筛选。"""
|
||||
|
||||
def test_validates_supported_expiry_formats(self) -> None:
|
||||
"""接受后端支持的日期时间格式并拒绝无效值。"""
|
||||
self.assertEqual(
|
||||
batch_update_package_expiry.validate_expires_at("2026-12-31 23:59:59"),
|
||||
"2026-12-31 23:59:59",
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "格式无效"):
|
||||
batch_update_package_expiry.validate_expires_at("2026/12/31")
|
||||
|
||||
def test_collects_only_active_packages_across_pages(self) -> None:
|
||||
"""分页收集状态为生效中的套餐。"""
|
||||
|
||||
class StubClient:
|
||||
"""返回两页套餐数据。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def get_json(self, path: str, token: str) -> SimpleNamespace:
|
||||
self.calls += 1
|
||||
return SimpleNamespace(
|
||||
status=200,
|
||||
body={
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"status": self.calls,
|
||||
"package_usage_id": self.calls,
|
||||
"package_name": f"套餐{self.calls}",
|
||||
"expires_at": "2026-01-01T00:00:00+08:00",
|
||||
}
|
||||
],
|
||||
"total": 101,
|
||||
},
|
||||
},
|
||||
raw_body="",
|
||||
)
|
||||
|
||||
client = StubClient()
|
||||
asset = batch_update_package_expiry.AssetInput(2, "8986000000000000001")
|
||||
targets = batch_update_package_expiry.find_active_packages(client, "token", asset)
|
||||
|
||||
self.assertEqual([target.package_usage_id for target in targets], [1])
|
||||
self.assertEqual(client.calls, 2)
|
||||
|
||||
def test_updates_package_with_requested_expiry(self) -> None:
|
||||
"""PATCH 路径和请求体应使用查询得到的套餐记录。"""
|
||||
|
||||
class StubClient:
|
||||
"""记录过期时间修改请求。"""
|
||||
|
||||
def patch_json(
|
||||
self, path: str, payload: dict[str, object], token: str
|
||||
) -> SimpleNamespace:
|
||||
self.path = path
|
||||
self.payload = payload
|
||||
return SimpleNamespace(
|
||||
status=200,
|
||||
body={"code": 0, "msg": "success", "data": {}},
|
||||
raw_body="",
|
||||
)
|
||||
|
||||
client = StubClient()
|
||||
target = batch_update_package_expiry.PackageTarget(
|
||||
source=batch_update_package_expiry.AssetInput(2, "8986000000000000001"),
|
||||
package_usage_id=88,
|
||||
package_name="测试套餐",
|
||||
old_expires_at="2026-01-01T00:00:00+08:00",
|
||||
)
|
||||
|
||||
row, success, code = batch_update_package_expiry.update_package_expiry(
|
||||
client, "token", target, "2026-12-31 23:59:59"
|
||||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(
|
||||
client.path,
|
||||
"/api/admin/assets/8986000000000000001/packages/88/expires-at",
|
||||
)
|
||||
self.assertEqual(client.payload, {"expires_at": "2026-12-31 23:59:59"})
|
||||
self.assertEqual(row["status"], "成功")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,54 +0,0 @@
|
||||
# 轮询系统压测指南
|
||||
|
||||
## 目标
|
||||
模拟 1000 万张卡的轮询场景,测试系统性能。
|
||||
|
||||
## 环境要求
|
||||
- Docker(运行本地 Redis)
|
||||
- 测试环境 PostgreSQL(已有)
|
||||
- 10+ CPU 核心
|
||||
- 16GB+ 内存
|
||||
|
||||
## 压测步骤
|
||||
|
||||
### Step 1: 启动本地 Redis
|
||||
```bash
|
||||
./scripts/benchmark/start_redis.sh
|
||||
```
|
||||
|
||||
### Step 2: 启动 Mock Gateway(模拟上游接口)
|
||||
```bash
|
||||
go run ./scripts/benchmark/mock_gateway.go
|
||||
```
|
||||
|
||||
### Step 3: 生成测试数据(1000万张卡)
|
||||
```bash
|
||||
go run ./scripts/benchmark/generate_cards.go
|
||||
```
|
||||
|
||||
### Step 4: 启动 Worker 进行压测
|
||||
```bash
|
||||
# 使用本地 Redis 配置 + Mock Gateway
|
||||
source .env.local && \
|
||||
JUNHONG_REDIS_ADDRESS=127.0.0.1 \
|
||||
JUNHONG_REDIS_PORT=6379 \
|
||||
JUNHONG_REDIS_PASSWORD="" \
|
||||
JUNHONG_REDIS_DB=0 \
|
||||
JUNHONG_GATEWAY_BASE_URL=http://127.0.0.1:8888 \
|
||||
JUNHONG_GATEWAY_APP_ID=test \
|
||||
JUNHONG_GATEWAY_APP_SECRET=testsecret123456 \
|
||||
JUNHONG_GATEWAY_TIMEOUT=30 \
|
||||
go run ./cmd/worker/...
|
||||
```
|
||||
|
||||
**注意**:可以启动多个 Worker 实例来增加并发处理能力。单个 Worker 通过 Asynq 已支持并发任务处理。
|
||||
|
||||
### Step 5: 监控压测状态
|
||||
```bash
|
||||
./scripts/benchmark/monitor.sh
|
||||
```
|
||||
|
||||
## 预期结果
|
||||
- 初始化时间:~50秒(1000万卡)
|
||||
- 调度吞吐:5万张/秒
|
||||
- 任务处理:取决于 Gateway 响应时间
|
||||
@@ -1,224 +0,0 @@
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// IotCard 简化的卡模型
|
||||
type IotCard struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ICCID string `gorm:"column:iccid;uniqueIndex:idx_iot_card_iccid,where:deleted_at IS NULL"`
|
||||
CardCategory string `gorm:"column:card_category;default:normal"`
|
||||
CarrierID uint `gorm:"column:carrier_id"`
|
||||
Status int `gorm:"column:status;default:1"`
|
||||
ActivationStatus int `gorm:"column:activation_status;default:0"`
|
||||
RealNameStatus int `gorm:"column:real_name_status;default:0"`
|
||||
NetworkStatus int `gorm:"column:network_status;default:0"`
|
||||
EnablePolling bool `gorm:"column:enable_polling;default:true"`
|
||||
Creator uint `gorm:"column:creator"`
|
||||
Updater uint `gorm:"column:updater"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
func (IotCard) TableName() string {
|
||||
return "tb_iot_card"
|
||||
}
|
||||
|
||||
var (
|
||||
totalCards = flag.Int("total", 10000000, "要生成的卡数量")
|
||||
batchSize = flag.Int("batch", 10000, "每批插入数量")
|
||||
workers = flag.Int("workers", 10, "并行 worker 数量")
|
||||
startICCID = flag.String("start", "898600000", "起始 ICCID 前缀(9位,总长度不超过20位)")
|
||||
clearOld = flag.Bool("clear", false, "是否清空现有测试卡")
|
||||
|
||||
insertedCount int64
|
||||
startTime time.Time
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
fmt.Println("=== 生成测试卡数据 ===")
|
||||
fmt.Printf("目标数量: %d 张\n", *totalCards)
|
||||
fmt.Printf("批次大小: %d\n", *batchSize)
|
||||
fmt.Printf("并行数: %d\n", *workers)
|
||||
fmt.Println("")
|
||||
|
||||
// 连接数据库
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv("JUNHONG_DATABASE_HOST"),
|
||||
os.Getenv("JUNHONG_DATABASE_PORT"),
|
||||
os.Getenv("JUNHONG_DATABASE_USER"),
|
||||
os.Getenv("JUNHONG_DATABASE_PASSWORD"),
|
||||
os.Getenv("JUNHONG_DATABASE_DBNAME"),
|
||||
)
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
|
||||
// 配置连接池
|
||||
sqlDB, _ := db.DB()
|
||||
sqlDB.SetMaxOpenConns(50)
|
||||
sqlDB.SetMaxIdleConns(25)
|
||||
|
||||
fmt.Println("✓ 数据库连接成功")
|
||||
|
||||
// 检查现有卡数量
|
||||
var existingCount int64
|
||||
db.Model(&IotCard{}).Count(&existingCount)
|
||||
fmt.Printf("现有卡数量: %d\n", existingCount)
|
||||
|
||||
if *clearOld {
|
||||
fmt.Println("清空现有测试卡...")
|
||||
// 只删除 ICCID 以 898600000 开头的测试卡
|
||||
db.Exec("DELETE FROM tb_iot_card WHERE iccid LIKE '898600000%'")
|
||||
fmt.Println("✓ 清空完成")
|
||||
}
|
||||
|
||||
// 开始生成
|
||||
startTime = time.Now()
|
||||
ctx := context.Background()
|
||||
|
||||
// 创建任务通道
|
||||
taskCh := make(chan int, *workers*2)
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 启动 worker
|
||||
for i := 0; i < *workers; i++ {
|
||||
wg.Add(1)
|
||||
go func(workerID int) {
|
||||
defer wg.Done()
|
||||
worker(ctx, db, workerID, taskCh)
|
||||
}(i)
|
||||
}
|
||||
|
||||
// 分发任务
|
||||
batches := *totalCards / *batchSize
|
||||
for i := 0; i < batches; i++ {
|
||||
taskCh <- i
|
||||
}
|
||||
close(taskCh)
|
||||
|
||||
// 等待完成
|
||||
wg.Wait()
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
fmt.Println("")
|
||||
fmt.Println("=== 生成完成 ===")
|
||||
fmt.Printf("总插入: %d 张\n", atomic.LoadInt64(&insertedCount))
|
||||
fmt.Printf("耗时: %v\n", elapsed)
|
||||
fmt.Printf("速度: %.0f 张/秒\n", float64(atomic.LoadInt64(&insertedCount))/elapsed.Seconds())
|
||||
|
||||
// 验证
|
||||
var finalCount int64
|
||||
db.Model(&IotCard{}).Count(&finalCount)
|
||||
fmt.Printf("数据库总卡数: %d\n", finalCount)
|
||||
}
|
||||
|
||||
func worker(ctx context.Context, db *gorm.DB, workerID int, taskCh <-chan int) {
|
||||
rng := rand.New(rand.NewSource(time.Now().UnixNano() + int64(workerID)))
|
||||
|
||||
for batchIndex := range taskCh {
|
||||
cards := generateBatch(rng, *startICCID, batchIndex, *batchSize)
|
||||
|
||||
// 批量插入
|
||||
err := db.WithContext(ctx).CreateInBatches(cards, 1000).Error
|
||||
if err != nil {
|
||||
log.Printf("Worker %d 插入失败: %v", workerID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
count := atomic.AddInt64(&insertedCount, int64(len(cards)))
|
||||
|
||||
// 进度报告
|
||||
if count%100000 == 0 {
|
||||
elapsed := time.Since(startTime).Seconds()
|
||||
speed := float64(count) / elapsed
|
||||
eta := float64(*totalCards-int(count)) / speed
|
||||
fmt.Printf("进度: %d/%d (%.1f%%) | 速度: %.0f/秒 | ETA: %.0f秒\n",
|
||||
count, *totalCards, float64(count)*100/float64(*totalCards), speed, eta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func generateBatch(rng *rand.Rand, iccidPrefix string, batchIndex int, size int) []IotCard {
|
||||
cards := make([]IotCard, size)
|
||||
now := time.Now()
|
||||
|
||||
for i := 0; i < size; i++ {
|
||||
// 使用前缀 + 序号生成 ICCID(总长度 20 位)
|
||||
// 例如: 898600000 (9位) + 00000000001 (11位) = 20 位
|
||||
cardIndex := batchIndex*size + i
|
||||
iccid := fmt.Sprintf("%s%011d", iccidPrefix, cardIndex)
|
||||
|
||||
// 随机分配状态(匹配轮询配置条件)
|
||||
// 实名状态: 0=未实名, 1=实名中, 2=已实名
|
||||
// 网络状态: 0=停机, 1=正常
|
||||
// 配置匹配逻辑:
|
||||
// - not_real_name: RealNameStatus == 0 或 1
|
||||
// - real_name: RealNameStatus == 2 && NetworkStatus != 1
|
||||
// - activated: RealNameStatus == 2 && NetworkStatus == 1
|
||||
r := rng.Float64()
|
||||
var realNameStatus, activationStatus, networkStatus int
|
||||
if r < 0.10 {
|
||||
// 10% 未实名 -> 匹配 not_real_name 配置
|
||||
realNameStatus = 0
|
||||
activationStatus = 0
|
||||
networkStatus = 0
|
||||
} else if r < 0.30 {
|
||||
// 20% 已实名未激活 -> 匹配 real_name 配置
|
||||
realNameStatus = 2
|
||||
activationStatus = 0
|
||||
networkStatus = 0
|
||||
} else {
|
||||
// 70% 已激活 -> 匹配 activated 配置(流量+套餐检查)
|
||||
realNameStatus = 2
|
||||
activationStatus = 1
|
||||
networkStatus = 1
|
||||
}
|
||||
|
||||
// 随机卡类型
|
||||
cardCategory := "normal"
|
||||
if rng.Float64() < 0.05 {
|
||||
cardCategory = "industry"
|
||||
}
|
||||
|
||||
cards[i] = IotCard{
|
||||
ICCID: iccid,
|
||||
CardCategory: cardCategory,
|
||||
CarrierID: uint(rng.Intn(3) + 1), // 1-3 运营商
|
||||
Status: 1,
|
||||
ActivationStatus: activationStatus,
|
||||
RealNameStatus: realNameStatus,
|
||||
NetworkStatus: networkStatus,
|
||||
EnablePolling: true,
|
||||
Creator: 1,
|
||||
Updater: 1,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
return cards
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// PollingConfig 轮询配置
|
||||
type PollingConfig struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ConfigName string `gorm:"column:config_name"`
|
||||
CardCondition *string `gorm:"column:card_condition"`
|
||||
CardCategory *string `gorm:"column:card_category"`
|
||||
CarrierID *uint `gorm:"column:carrier_id"`
|
||||
Priority int `gorm:"column:priority"`
|
||||
RealnameCheckInterval *int `gorm:"column:realname_check_interval"`
|
||||
CarddataCheckInterval *int `gorm:"column:carddata_check_interval"`
|
||||
PackageCheckInterval *int `gorm:"column:package_check_interval"`
|
||||
Status int `gorm:"column:status;default:1"`
|
||||
Description string `gorm:"column:description"`
|
||||
}
|
||||
|
||||
func (PollingConfig) TableName() string {
|
||||
return "tb_polling_config"
|
||||
}
|
||||
|
||||
// PollingConcurrencyConfig 并发控制配置
|
||||
type PollingConcurrencyConfig struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
TaskType string `gorm:"column:task_type"`
|
||||
MaxConcurrency int `gorm:"column:max_concurrency"`
|
||||
Description string `gorm:"column:description"`
|
||||
}
|
||||
|
||||
func (PollingConcurrencyConfig) TableName() string {
|
||||
return "tb_polling_concurrency_config"
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("=== 初始化轮询配置 ===")
|
||||
|
||||
// 连接数据库
|
||||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||||
os.Getenv("JUNHONG_DATABASE_HOST"),
|
||||
os.Getenv("JUNHONG_DATABASE_PORT"),
|
||||
os.Getenv("JUNHONG_DATABASE_USER"),
|
||||
os.Getenv("JUNHONG_DATABASE_PASSWORD"),
|
||||
os.Getenv("JUNHONG_DATABASE_DBNAME"),
|
||||
)
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
fmt.Println("✓ 数据库连接成功")
|
||||
|
||||
// 清空现有配置
|
||||
db.Exec("DELETE FROM tb_polling_config")
|
||||
db.Exec("DELETE FROM tb_polling_concurrency_config")
|
||||
fmt.Println("✓ 清空现有配置")
|
||||
|
||||
// 插入轮询配置
|
||||
configs := []PollingConfig{
|
||||
{
|
||||
ConfigName: "未实名卡轮询",
|
||||
CardCondition: ptr("not_real_name"),
|
||||
Priority: 10,
|
||||
RealnameCheckInterval: ptr(300), // 5分钟
|
||||
Status: 1,
|
||||
Description: "未实名卡每5分钟检查一次实名状态",
|
||||
},
|
||||
{
|
||||
ConfigName: "行业卡轮询",
|
||||
CardCategory: ptr("industry"),
|
||||
Priority: 15,
|
||||
CarddataCheckInterval: ptr(3600), // 1小时
|
||||
PackageCheckInterval: ptr(3600),
|
||||
Status: 1,
|
||||
Description: "行业卡无需实名检查,每小时检查流量和套餐",
|
||||
},
|
||||
{
|
||||
ConfigName: "已实名卡轮询",
|
||||
CardCondition: ptr("real_name"),
|
||||
Priority: 20,
|
||||
RealnameCheckInterval: ptr(86400), // 1天
|
||||
Status: 1,
|
||||
Description: "已实名卡每天检查一次实名状态",
|
||||
},
|
||||
{
|
||||
ConfigName: "已激活卡轮询",
|
||||
CardCondition: ptr("activated"),
|
||||
Priority: 30,
|
||||
CarddataCheckInterval: ptr(3600), // 1小时
|
||||
PackageCheckInterval: ptr(3600),
|
||||
Status: 1,
|
||||
Description: "已激活卡每小时检查流量和套餐",
|
||||
},
|
||||
{
|
||||
ConfigName: "默认轮询配置",
|
||||
Priority: 100,
|
||||
RealnameCheckInterval: ptr(86400),
|
||||
CarddataCheckInterval: ptr(86400),
|
||||
PackageCheckInterval: ptr(86400),
|
||||
Status: 1,
|
||||
Description: "默认配置,每天检查一次",
|
||||
},
|
||||
}
|
||||
|
||||
for _, cfg := range configs {
|
||||
if err := db.Create(&cfg).Error; err != nil {
|
||||
log.Printf("插入配置失败 [%s]: %v", cfg.ConfigName, err)
|
||||
} else {
|
||||
fmt.Printf(" + %s (优先级: %d)\n", cfg.ConfigName, cfg.Priority)
|
||||
}
|
||||
}
|
||||
fmt.Println("✓ 轮询配置初始化完成")
|
||||
|
||||
// 插入并发控制配置(5+ Worker 场景,每种任务 2000-5000 并发)
|
||||
concurrencyConfigs := []PollingConcurrencyConfig{
|
||||
{TaskType: "realname", MaxConcurrency: 5000, Description: "实名检查任务最大并发数"},
|
||||
{TaskType: "carddata", MaxConcurrency: 5000, Description: "流量检查任务最大并发数"},
|
||||
{TaskType: "package", MaxConcurrency: 5000, Description: "套餐检查任务最大并发数"},
|
||||
{TaskType: "stop_start", MaxConcurrency: 5000, Description: "停复机操作最大并发数"},
|
||||
}
|
||||
|
||||
for _, cfg := range concurrencyConfigs {
|
||||
if err := db.Create(&cfg).Error; err != nil {
|
||||
log.Printf("插入并发配置失败 [%s]: %v", cfg.TaskType, err)
|
||||
} else {
|
||||
fmt.Printf(" + %s (最大并发: %d)\n", cfg.TaskType, cfg.MaxConcurrency)
|
||||
}
|
||||
}
|
||||
fmt.Println("✓ 并发控制配置初始化完成")
|
||||
|
||||
// 验证
|
||||
var pollingCount, concurrencyCount int64
|
||||
db.Model(&PollingConfig{}).Count(&pollingCount)
|
||||
db.Model(&PollingConcurrencyConfig{}).Count(&concurrencyCount)
|
||||
fmt.Printf("\n=== 初始化完成 ===\n")
|
||||
fmt.Printf("轮询配置: %d 条\n", pollingCount)
|
||||
fmt.Printf("并发配置: %d 条\n", concurrencyCount)
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
//go:build ignore
|
||||
// +build ignore
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 统计计数器
|
||||
var (
|
||||
totalRequests int64
|
||||
successRequests int64
|
||||
failedRequests int64
|
||||
startTime time.Time
|
||||
fastMode bool // 快速模式:低延迟
|
||||
)
|
||||
|
||||
// GatewayResponse 模拟网关响应
|
||||
type GatewayResponse struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TraceID string `json:"traceId"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
startTime = time.Now()
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
|
||||
// 检查是否启用快速模式
|
||||
if os.Getenv("FAST_MODE") == "1" || os.Getenv("FAST_MODE") == "true" {
|
||||
fastMode = true
|
||||
fmt.Println("⚡ 快速模式已启用(延迟: 10-50ms)")
|
||||
} else {
|
||||
fmt.Println("🐢 真实模式(延迟: 200ms-4s)")
|
||||
fmt.Println(" 提示: 设置 FAST_MODE=1 可启用快速模式")
|
||||
}
|
||||
|
||||
// 实名查询接口(匹配 gateway client 的路径)
|
||||
http.HandleFunc("/flow-card/realName", handleRealnameQuery)
|
||||
|
||||
// 流量查询接口
|
||||
http.HandleFunc("/flow-card/flow", handleFlowQuery)
|
||||
|
||||
// 停机接口
|
||||
http.HandleFunc("/flow-card/cardStop", handleStopCard)
|
||||
|
||||
// 复机接口
|
||||
http.HandleFunc("/flow-card/cardStart", handleStartCard)
|
||||
|
||||
// 卡状态查询接口
|
||||
http.HandleFunc("/flow-card/status", handleCardStatus)
|
||||
|
||||
// 统计接口
|
||||
http.HandleFunc("/stats", handleStats)
|
||||
|
||||
fmt.Println("=== Mock Gateway 服务器启动 ===")
|
||||
fmt.Println("监听端口: 8888")
|
||||
fmt.Println("模拟响应时间: 200ms - 4s")
|
||||
fmt.Println("")
|
||||
fmt.Println("接口列表:")
|
||||
fmt.Println(" POST /flow-card/realName - 实名查询")
|
||||
fmt.Println(" POST /flow-card/flow - 流量查询")
|
||||
fmt.Println(" POST /flow-card/status - 卡状态查询")
|
||||
fmt.Println(" POST /flow-card/cardStop - 停机操作")
|
||||
fmt.Println(" POST /flow-card/cardStart - 复机操作")
|
||||
fmt.Println(" GET /stats - 查看统计")
|
||||
fmt.Println("")
|
||||
fmt.Println("按 Ctrl+C 停止服务器")
|
||||
|
||||
log.Fatal(http.ListenAndServe(":8888", nil))
|
||||
}
|
||||
|
||||
// simulateLatency 模拟网络延迟
|
||||
func simulateLatency() {
|
||||
var delay time.Duration
|
||||
|
||||
if fastMode {
|
||||
// 快速模式:10-50ms
|
||||
delay = time.Duration(10+rand.Intn(40)) * time.Millisecond
|
||||
} else {
|
||||
// 真实模式:200ms - 4s
|
||||
// 80% 概率 200-500ms(正常)
|
||||
// 15% 概率 500ms-2s(较慢)
|
||||
// 5% 概率 2s-4s(很慢)
|
||||
r := rand.Float64()
|
||||
if r < 0.80 {
|
||||
delay = time.Duration(200+rand.Intn(300)) * time.Millisecond
|
||||
} else if r < 0.95 {
|
||||
delay = time.Duration(500+rand.Intn(1500)) * time.Millisecond
|
||||
} else {
|
||||
delay = time.Duration(2000+rand.Intn(2000)) * time.Millisecond
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
// handleRealnameQuery 处理实名查询
|
||||
func handleRealnameQuery(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&totalRequests, 1)
|
||||
simulateLatency()
|
||||
|
||||
// 90% 成功,10% 失败
|
||||
if rand.Float64() < 0.90 {
|
||||
atomic.AddInt64(&successRequests, 1)
|
||||
// 随机返回实名状态(匹配文档:realStatus 为 bool 类型)
|
||||
realStatus := rand.Float64() < 0.5
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "success",
|
||||
TraceID: fmt.Sprintf("trace-%d", time.Now().UnixNano()),
|
||||
Data: json.RawMessage(fmt.Sprintf(`{"iccid": "mock-iccid", "realStatus": %t}`, realStatus)),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
} else {
|
||||
atomic.AddInt64(&failedRequests, 1)
|
||||
resp := GatewayResponse{
|
||||
Code: 500,
|
||||
Msg: "upstream error",
|
||||
TraceID: fmt.Sprintf("trace-%d", time.Now().UnixNano()),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
}
|
||||
|
||||
// handleFlowQuery 处理流量查询
|
||||
func handleFlowQuery(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&totalRequests, 1)
|
||||
simulateLatency()
|
||||
|
||||
if rand.Float64() < 0.90 {
|
||||
atomic.AddInt64(&successRequests, 1)
|
||||
// 随机返回流量数据(匹配文档:used 字段)
|
||||
usedFlow := rand.Intn(10000)
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "success",
|
||||
TraceID: fmt.Sprintf("trace-%d", time.Now().UnixNano()),
|
||||
Data: json.RawMessage(fmt.Sprintf(`{"iccid": "mock-iccid", "used": %d, "unit": "MB"}`, usedFlow)),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
} else {
|
||||
atomic.AddInt64(&failedRequests, 1)
|
||||
resp := GatewayResponse{
|
||||
Code: 500,
|
||||
Msg: "upstream error",
|
||||
TraceID: fmt.Sprintf("trace-%d", time.Now().UnixNano()),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStopCard 处理停机操作
|
||||
func handleStopCard(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&totalRequests, 1)
|
||||
simulateLatency()
|
||||
|
||||
if rand.Float64() < 0.95 {
|
||||
atomic.AddInt64(&successRequests, 1)
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "success",
|
||||
TraceID: fmt.Sprintf("trace-%d", time.Now().UnixNano()),
|
||||
Data: json.RawMessage(`{"result": "stopped"}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
} else {
|
||||
atomic.AddInt64(&failedRequests, 1)
|
||||
resp := GatewayResponse{
|
||||
Code: 500,
|
||||
Msg: "stop failed",
|
||||
TraceID: fmt.Sprintf("trace-%d", time.Now().UnixNano()),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStartCard 处理复机操作
|
||||
func handleStartCard(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&totalRequests, 1)
|
||||
simulateLatency()
|
||||
|
||||
if rand.Float64() < 0.95 {
|
||||
atomic.AddInt64(&successRequests, 1)
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "success",
|
||||
TraceID: fmt.Sprintf("trace-%d", time.Now().UnixNano()),
|
||||
Data: json.RawMessage(`{"result": "started"}`),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
} else {
|
||||
atomic.AddInt64(&failedRequests, 1)
|
||||
resp := GatewayResponse{
|
||||
Code: 500,
|
||||
Msg: "start failed",
|
||||
TraceID: fmt.Sprintf("trace-%d", time.Now().UnixNano()),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
}
|
||||
|
||||
// handleCardStatus 处理卡状态查询
|
||||
func handleCardStatus(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt64(&totalRequests, 1)
|
||||
simulateLatency()
|
||||
|
||||
if rand.Float64() < 0.90 {
|
||||
atomic.AddInt64(&successRequests, 1)
|
||||
// 随机返回卡状态:1-正常,0-停机
|
||||
cardStatus := rand.Intn(2)
|
||||
resp := GatewayResponse{
|
||||
Code: 200,
|
||||
Msg: "success",
|
||||
TraceID: fmt.Sprintf("trace-%d", time.Now().UnixNano()),
|
||||
Data: json.RawMessage(fmt.Sprintf(`{"status": %d}`, cardStatus)),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
} else {
|
||||
atomic.AddInt64(&failedRequests, 1)
|
||||
resp := GatewayResponse{
|
||||
Code: 500,
|
||||
Msg: "query failed",
|
||||
TraceID: fmt.Sprintf("trace-%d", time.Now().UnixNano()),
|
||||
}
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
}
|
||||
|
||||
// handleStats 返回统计信息
|
||||
func handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
elapsed := time.Since(startTime).Seconds()
|
||||
total := atomic.LoadInt64(&totalRequests)
|
||||
success := atomic.LoadInt64(&successRequests)
|
||||
failed := atomic.LoadInt64(&failedRequests)
|
||||
|
||||
qps := float64(total) / elapsed
|
||||
successRate := float64(0)
|
||||
if total > 0 {
|
||||
successRate = float64(success) * 100 / float64(total)
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"uptime_seconds": elapsed,
|
||||
"total_requests": total,
|
||||
"success_count": success,
|
||||
"failed_count": failed,
|
||||
"qps": qps,
|
||||
"success_rate": fmt.Sprintf("%.2f%%", successRate),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(stats)
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 压测监控脚本 - 增强版
|
||||
|
||||
set -e
|
||||
|
||||
# 检查 Redis 连接
|
||||
REDIS_HOST="${JUNHONG_REDIS_ADDRESS:-127.0.0.1}"
|
||||
REDIS_PORT="${JUNHONG_REDIS_PORT:-6379}"
|
||||
REDIS_CLI="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
|
||||
|
||||
# 上一次的统计值(用于计算增量)
|
||||
LAST_REALNAME_SUCCESS=0
|
||||
LAST_REALNAME_FAILURE=0
|
||||
LAST_CARDDATA_SUCCESS=0
|
||||
LAST_CARDDATA_FAILURE=0
|
||||
LAST_PACKAGE_SUCCESS=0
|
||||
LAST_PACKAGE_FAILURE=0
|
||||
LAST_TIME=$(date +%s)
|
||||
|
||||
echo "=== 轮询系统压测监控(增强版)==="
|
||||
echo "Redis 地址: $REDIS_HOST:$REDIS_PORT"
|
||||
echo ""
|
||||
|
||||
# 循环监控
|
||||
while true; do
|
||||
clear
|
||||
NOW=$(date +%s)
|
||||
INTERVAL=$((NOW - LAST_TIME))
|
||||
if [ $INTERVAL -eq 0 ]; then
|
||||
INTERVAL=1
|
||||
fi
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 轮询系统压测监控 $(date '+%Y-%m-%d %H:%M:%S') ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# ========== Redis 队列状态 ==========
|
||||
echo "【📊 Redis 队列状态】"
|
||||
REALNAME_QUEUE=$($REDIS_CLI ZCARD "polling:queue:realname" 2>/dev/null || echo "0")
|
||||
CARDDATA_QUEUE=$($REDIS_CLI ZCARD "polling:queue:carddata" 2>/dev/null || echo "0")
|
||||
PACKAGE_QUEUE=$($REDIS_CLI ZCARD "polling:queue:package" 2>/dev/null || echo "0")
|
||||
MANUAL_REALNAME=$($REDIS_CLI LLEN "polling:manual:realname" 2>/dev/null || echo "0")
|
||||
MANUAL_CARDDATA=$($REDIS_CLI LLEN "polling:manual:carddata" 2>/dev/null || echo "0")
|
||||
MANUAL_PACKAGE=$($REDIS_CLI LLEN "polling:manual:package" 2>/dev/null || echo "0")
|
||||
|
||||
printf " %-20s %'12d\n" "实名检查队列:" "$REALNAME_QUEUE"
|
||||
printf " %-20s %'12d\n" "流量检查队列:" "$CARDDATA_QUEUE"
|
||||
printf " %-20s %'12d\n" "套餐检查队列:" "$PACKAGE_QUEUE"
|
||||
printf " %-20s %'12d\n" "手动触发(实名):" "$MANUAL_REALNAME"
|
||||
printf " %-20s %'12d\n" "手动触发(流量):" "$MANUAL_CARDDATA"
|
||||
printf " %-20s %'12d\n" "手动触发(套餐):" "$MANUAL_PACKAGE"
|
||||
echo ""
|
||||
|
||||
# ========== 处理性能统计 ==========
|
||||
echo "【⚡ 处理性能统计】"
|
||||
|
||||
# 获取当前统计值(注意:key 格式是 polling:stats:polling:xxx)
|
||||
REALNAME_SUCCESS=$($REDIS_CLI HGET "polling:stats:polling:realname" "success_count_1h" 2>/dev/null || echo "0")
|
||||
REALNAME_FAILURE=$($REDIS_CLI HGET "polling:stats:polling:realname" "failure_count_1h" 2>/dev/null || echo "0")
|
||||
REALNAME_DURATION=$($REDIS_CLI HGET "polling:stats:polling:realname" "total_duration_1h" 2>/dev/null || echo "0")
|
||||
|
||||
CARDDATA_SUCCESS=$($REDIS_CLI HGET "polling:stats:polling:carddata" "success_count_1h" 2>/dev/null || echo "0")
|
||||
CARDDATA_FAILURE=$($REDIS_CLI HGET "polling:stats:polling:carddata" "failure_count_1h" 2>/dev/null || echo "0")
|
||||
CARDDATA_DURATION=$($REDIS_CLI HGET "polling:stats:polling:carddata" "total_duration_1h" 2>/dev/null || echo "0")
|
||||
|
||||
PACKAGE_SUCCESS=$($REDIS_CLI HGET "polling:stats:polling:package" "success_count_1h" 2>/dev/null || echo "0")
|
||||
PACKAGE_FAILURE=$($REDIS_CLI HGET "polling:stats:polling:package" "failure_count_1h" 2>/dev/null || echo "0")
|
||||
PACKAGE_DURATION=$($REDIS_CLI HGET "polling:stats:polling:package" "total_duration_1h" 2>/dev/null || echo "0")
|
||||
|
||||
# 设置默认值
|
||||
REALNAME_SUCCESS=${REALNAME_SUCCESS:-0}
|
||||
REALNAME_FAILURE=${REALNAME_FAILURE:-0}
|
||||
REALNAME_DURATION=${REALNAME_DURATION:-0}
|
||||
CARDDATA_SUCCESS=${CARDDATA_SUCCESS:-0}
|
||||
CARDDATA_FAILURE=${CARDDATA_FAILURE:-0}
|
||||
CARDDATA_DURATION=${CARDDATA_DURATION:-0}
|
||||
PACKAGE_SUCCESS=${PACKAGE_SUCCESS:-0}
|
||||
PACKAGE_FAILURE=${PACKAGE_FAILURE:-0}
|
||||
PACKAGE_DURATION=${PACKAGE_DURATION:-0}
|
||||
|
||||
# 计算增量和 QPS
|
||||
REALNAME_SUCCESS_DELTA=$((REALNAME_SUCCESS - LAST_REALNAME_SUCCESS))
|
||||
REALNAME_FAILURE_DELTA=$((REALNAME_FAILURE - LAST_REALNAME_FAILURE))
|
||||
CARDDATA_SUCCESS_DELTA=$((CARDDATA_SUCCESS - LAST_CARDDATA_SUCCESS))
|
||||
CARDDATA_FAILURE_DELTA=$((CARDDATA_FAILURE - LAST_CARDDATA_FAILURE))
|
||||
PACKAGE_SUCCESS_DELTA=$((PACKAGE_SUCCESS - LAST_PACKAGE_SUCCESS))
|
||||
PACKAGE_FAILURE_DELTA=$((PACKAGE_FAILURE - LAST_PACKAGE_FAILURE))
|
||||
|
||||
REALNAME_QPS=$((REALNAME_SUCCESS_DELTA / INTERVAL))
|
||||
CARDDATA_QPS=$((CARDDATA_SUCCESS_DELTA / INTERVAL))
|
||||
PACKAGE_QPS=$((PACKAGE_SUCCESS_DELTA / INTERVAL))
|
||||
TOTAL_QPS=$((REALNAME_QPS + CARDDATA_QPS + PACKAGE_QPS))
|
||||
|
||||
# 计算成功率
|
||||
REALNAME_TOTAL=$((REALNAME_SUCCESS + REALNAME_FAILURE))
|
||||
CARDDATA_TOTAL=$((CARDDATA_SUCCESS + CARDDATA_FAILURE))
|
||||
PACKAGE_TOTAL=$((PACKAGE_SUCCESS + PACKAGE_FAILURE))
|
||||
|
||||
if [ $REALNAME_TOTAL -gt 0 ]; then
|
||||
REALNAME_RATE=$(echo "scale=1; $REALNAME_SUCCESS * 100 / $REALNAME_TOTAL" | bc)
|
||||
else
|
||||
REALNAME_RATE="0.0"
|
||||
fi
|
||||
if [ $CARDDATA_TOTAL -gt 0 ]; then
|
||||
CARDDATA_RATE=$(echo "scale=1; $CARDDATA_SUCCESS * 100 / $CARDDATA_TOTAL" | bc)
|
||||
else
|
||||
CARDDATA_RATE="0.0"
|
||||
fi
|
||||
if [ $PACKAGE_TOTAL -gt 0 ]; then
|
||||
PACKAGE_RATE=$(echo "scale=1; $PACKAGE_SUCCESS * 100 / $PACKAGE_TOTAL" | bc)
|
||||
else
|
||||
PACKAGE_RATE="0.0"
|
||||
fi
|
||||
|
||||
# 计算平均延迟
|
||||
if [ $REALNAME_SUCCESS -gt 0 ]; then
|
||||
REALNAME_AVG_MS=$((REALNAME_DURATION / REALNAME_SUCCESS))
|
||||
else
|
||||
REALNAME_AVG_MS=0
|
||||
fi
|
||||
if [ $CARDDATA_SUCCESS -gt 0 ]; then
|
||||
CARDDATA_AVG_MS=$((CARDDATA_DURATION / CARDDATA_SUCCESS))
|
||||
else
|
||||
CARDDATA_AVG_MS=0
|
||||
fi
|
||||
if [ $PACKAGE_SUCCESS -gt 0 ]; then
|
||||
PACKAGE_AVG_MS=$((PACKAGE_DURATION / PACKAGE_SUCCESS))
|
||||
else
|
||||
PACKAGE_AVG_MS=0
|
||||
fi
|
||||
|
||||
printf " %-10s | %8s | %8s | %6s | %6s | %8s\n" "任务类型" "成功" "失败" "成功率" "QPS" "平均延迟"
|
||||
printf " %-10s | %8s | %8s | %6s | %6s | %8s\n" "----------" "--------" "--------" "------" "------" "--------"
|
||||
printf " %-10s | %'8d | %'8d | %5.1f%% | %6d | %6dms\n" "实名检查" "$REALNAME_SUCCESS" "$REALNAME_FAILURE" "$REALNAME_RATE" "$REALNAME_QPS" "$REALNAME_AVG_MS"
|
||||
printf " %-10s | %'8d | %'8d | %5.1f%% | %6d | %6dms\n" "流量检查" "$CARDDATA_SUCCESS" "$CARDDATA_FAILURE" "$CARDDATA_RATE" "$CARDDATA_QPS" "$CARDDATA_AVG_MS"
|
||||
printf " %-10s | %'8d | %'8d | %5.1f%% | %6d | %6dms\n" "套餐检查" "$PACKAGE_SUCCESS" "$PACKAGE_FAILURE" "$PACKAGE_RATE" "$PACKAGE_QPS" "$PACKAGE_AVG_MS"
|
||||
printf " %-10s | %8s | %8s | %6s | %6d | %8s\n" "总计" "-" "-" "-" "$TOTAL_QPS" "-"
|
||||
echo ""
|
||||
|
||||
# 更新上次值
|
||||
LAST_REALNAME_SUCCESS=$REALNAME_SUCCESS
|
||||
LAST_REALNAME_FAILURE=$REALNAME_FAILURE
|
||||
LAST_CARDDATA_SUCCESS=$CARDDATA_SUCCESS
|
||||
LAST_CARDDATA_FAILURE=$CARDDATA_FAILURE
|
||||
LAST_PACKAGE_SUCCESS=$PACKAGE_SUCCESS
|
||||
LAST_PACKAGE_FAILURE=$PACKAGE_FAILURE
|
||||
LAST_TIME=$NOW
|
||||
|
||||
# ========== 并发控制状态 ==========
|
||||
echo "【🔒 并发控制状态】"
|
||||
# 注意:current key 包含 polling: 前缀,config key 不包含
|
||||
REALNAME_CURRENT=$($REDIS_CLI GET "polling:concurrency:current:polling:realname" 2>/dev/null || echo "0")
|
||||
REALNAME_MAX=$($REDIS_CLI GET "polling:concurrency:config:realname" 2>/dev/null || echo "50")
|
||||
CARDDATA_CURRENT=$($REDIS_CLI GET "polling:concurrency:current:polling:carddata" 2>/dev/null || echo "0")
|
||||
CARDDATA_MAX=$($REDIS_CLI GET "polling:concurrency:config:carddata" 2>/dev/null || echo "50")
|
||||
PACKAGE_CURRENT=$($REDIS_CLI GET "polling:concurrency:current:polling:package" 2>/dev/null || echo "0")
|
||||
PACKAGE_MAX=$($REDIS_CLI GET "polling:concurrency:config:package" 2>/dev/null || echo "50")
|
||||
|
||||
REALNAME_CURRENT=${REALNAME_CURRENT:-0}
|
||||
REALNAME_MAX=${REALNAME_MAX:-50}
|
||||
CARDDATA_CURRENT=${CARDDATA_CURRENT:-0}
|
||||
CARDDATA_MAX=${CARDDATA_MAX:-50}
|
||||
PACKAGE_CURRENT=${PACKAGE_CURRENT:-0}
|
||||
PACKAGE_MAX=${PACKAGE_MAX:-50}
|
||||
|
||||
if [ "$REALNAME_MAX" = "50" ] && [ -z "$($REDIS_CLI GET "polling:concurrency:config:realname" 2>/dev/null)" ]; then
|
||||
echo " (未启动 Worker,并发配置未加载)"
|
||||
else
|
||||
printf " 实名检查: %d / %s\n" "$REALNAME_CURRENT" "$REALNAME_MAX"
|
||||
printf " 流量检查: %d / %s\n" "$CARDDATA_CURRENT" "$CARDDATA_MAX"
|
||||
printf " 套餐检查: %d / %s\n" "$PACKAGE_CURRENT" "$PACKAGE_MAX"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ========== Mock Gateway 统计 ==========
|
||||
if curl -s http://127.0.0.1:8888/stats > /dev/null 2>&1; then
|
||||
echo "【🌐 Mock Gateway 统计】"
|
||||
GATEWAY_STATS=$(curl -s http://127.0.0.1:8888/stats 2>/dev/null)
|
||||
if [ -n "$GATEWAY_STATS" ]; then
|
||||
echo "$GATEWAY_STATS" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
uptime = data.get('uptime_seconds', 0)
|
||||
total = data.get('total_requests', 0)
|
||||
success = data.get('success_count', 0)
|
||||
failed = data.get('failed_count', 0)
|
||||
qps = data.get('qps', 0)
|
||||
rate = data.get('success_rate', '0%')
|
||||
print(f' 运行时长: {uptime:.0f}s | 总请求: {total:,} | QPS: {qps:.1f} | 成功率: {rate}')
|
||||
except Exception as e:
|
||||
print(f' 解析失败: {e}')
|
||||
" 2>/dev/null || echo " 解析失败"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# ========== Redis 内存 ==========
|
||||
echo "【💾 Redis 内存使用】"
|
||||
REDIS_INFO=$($REDIS_CLI INFO memory 2>/dev/null)
|
||||
if [ -n "$REDIS_INFO" ]; then
|
||||
USED_MEMORY=$(echo "$REDIS_INFO" | grep "used_memory_human:" | cut -d: -f2 | tr -d '\r')
|
||||
MAX_MEMORY=$(echo "$REDIS_INFO" | grep "maxmemory_human:" | cut -d: -f2 | tr -d '\r')
|
||||
printf " 已用: %s / 最大: %s\n" "$USED_MEMORY" "$MAX_MEMORY"
|
||||
else
|
||||
echo " 无法获取 Redis 信息"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ========== 数据库统计(从 Redis 计算)==========
|
||||
echo "【📦 卡统计(队列推算)】"
|
||||
TOTAL_QUEUE=$((REALNAME_QUEUE + CARDDATA_QUEUE + PACKAGE_QUEUE))
|
||||
# 根据配置推算:未实名进入实名队列,已激活进入流量和套餐队列
|
||||
# 这只是近似值,实际统计需要查数据库
|
||||
printf " 队列总卡数: %'d\n" "$TOTAL_QUEUE"
|
||||
printf " 未实名(估): %'d | 已激活(估): %'d\n" "$REALNAME_QUEUE" "$CARDDATA_QUEUE"
|
||||
echo " (注: 精确统计需要数据库连接)"
|
||||
echo ""
|
||||
|
||||
echo "────────────────────────────────────────────────────────────────────────"
|
||||
echo "按 Ctrl+C 退出监控... (每 5 秒刷新)"
|
||||
sleep 5
|
||||
done
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 启动本地 Redis 用于压测
|
||||
|
||||
set -e
|
||||
|
||||
echo "=== 启动本地 Redis ==="
|
||||
|
||||
# 检查是否已有容器在运行
|
||||
if docker ps | grep -q polling-redis; then
|
||||
echo "Redis 容器已在运行"
|
||||
docker ps | grep polling-redis
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 停止并删除旧容器(如果存在)
|
||||
docker rm -f polling-redis 2>/dev/null || true
|
||||
|
||||
# 启动 Redis 容器
|
||||
# - 16GB maxmemory(压测用)
|
||||
# - 禁用持久化(提高性能)
|
||||
docker run -d \
|
||||
--name polling-redis \
|
||||
-p 6379:6379 \
|
||||
redis:7-alpine \
|
||||
redis-server \
|
||||
--maxmemory 8gb \
|
||||
--maxmemory-policy allkeys-lru \
|
||||
--appendonly no \
|
||||
--save ""
|
||||
|
||||
echo ""
|
||||
echo "等待 Redis 启动..."
|
||||
sleep 2
|
||||
|
||||
# 验证连接
|
||||
if redis-cli ping | grep -q PONG; then
|
||||
echo "✓ Redis 启动成功"
|
||||
echo ""
|
||||
echo "连接信息:"
|
||||
echo " 地址: 127.0.0.1:6379"
|
||||
echo " 密码: (无)"
|
||||
echo ""
|
||||
echo "Redis 内存配置:"
|
||||
redis-cli CONFIG GET maxmemory
|
||||
else
|
||||
echo "✗ Redis 启动失败"
|
||||
exit 1
|
||||
fi
|
||||
88
scripts/context-health.sh
Executable file
88
scripts/context-health.sh
Executable file
@@ -0,0 +1,88 @@
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
ROOT=${0:A:h:h}
|
||||
cd "$ROOT"
|
||||
|
||||
openspec doctor --json | grep -q '"healthy": true'
|
||||
openspec validate --all >/dev/null
|
||||
|
||||
for forbidden in .planning .scratch .sisyphus CONTEXT.md specs; do
|
||||
[[ ! -e "$forbidden" ]] || { print -u2 "禁止目录或文件仍存在:$forbidden"; exit 1; }
|
||||
done
|
||||
|
||||
[[ -z "$(find "$HOME/.codex/skills" -maxdepth 1 -name 'gsd-*' -print 2>/dev/null)" ]]
|
||||
[[ -z "$(find "$HOME/.codex/agents" -maxdepth 1 -name 'gsd-*' -print 2>/dev/null)" ]]
|
||||
[[ -z "$(command -v omx 2>/dev/null || true)" ]]
|
||||
! grep -RIlE 'oh-my-codex|OMX:' AGENTS.md CLAUDE.md .agents .claude >/dev/null 2>&1
|
||||
[[ -z "$(git ls-files .lh-harness)" ]]
|
||||
[[ -z "$(find . -type f -name '*_test.go' -not -path './.git/*' -print)" ]]
|
||||
[[ ! -e tests ]]
|
||||
[[ ! -e internal/testutil ]]
|
||||
[[ ! -e scripts/benchmark ]]
|
||||
[[ -z "$(find scripts -type f -name 'test_*' -print 2>/dev/null)" ]]
|
||||
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
import json, re, sys
|
||||
files=[Path('AGENTS.md'),Path('CLAUDE.md'),Path('README.md'),Path('ARCHITECTURE.md'),*Path('docs').rglob('*.md'),*Path('openspec').rglob('*.md')]
|
||||
bad=[]
|
||||
for p in files:
|
||||
for link in re.findall(r'\[[^]]*\]\(([^)]+)\)', p.read_text(errors='ignore')):
|
||||
target=link.split('#')[0]
|
||||
if not target or '://' in target or target.startswith('mailto:'):
|
||||
continue
|
||||
if not (p.parent/target).resolve().exists():
|
||||
bad.append(f'{p}: {link}')
|
||||
if bad:
|
||||
print('失效链接:\n'+'\n'.join(bad), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
spec_requirements=set()
|
||||
indexed_routes=set()
|
||||
for spec in Path('openspec/specs').glob('*/spec.md'):
|
||||
text=spec.read_text()
|
||||
if re.search(r'^### Requirement: .*接口集合$', text, re.M):
|
||||
raise SystemExit(f'接口集合仍被用作行为 Requirement:{spec}')
|
||||
names=re.findall(r'^### Requirement: (.+)$', text, re.M)
|
||||
if not names:
|
||||
raise SystemExit(f'Capability 缺少行为 Requirement:{spec}')
|
||||
spec_requirements.update(f'{spec.parent.name}::{name}' for name in names)
|
||||
indexed_routes.update(f'{method} {path}' for method,path in re.findall(r'`(GET|POST|PUT|PATCH|DELETE) ([^`]+)`', text))
|
||||
|
||||
evidence_dir=Path('.lh-harness/context-reset')
|
||||
evidence=json.loads((evidence_dir/'requirement-evidence.json').read_text())
|
||||
matrix=json.loads((evidence_dir/'entry-capability-requirement-matrix.json').read_text())
|
||||
evidence_requirements={f"{row['capability']}::{row['requirement']}" for row in evidence}
|
||||
if evidence_requirements != spec_requirements:
|
||||
raise SystemExit('Requirement 证据链与 Specs 不一致')
|
||||
matrix_http={row['entry'] for row in matrix if row['entry_type']=='http'}
|
||||
if matrix_http != indexed_routes:
|
||||
raise SystemExit('HTTP Route 与可达操作索引双向覆盖不一致')
|
||||
linked={name for row in matrix for name in row['requirements']}
|
||||
if linked != spec_requirements:
|
||||
raise SystemExit('入口矩阵未双向覆盖全部行为 Requirement')
|
||||
worker_source=Path('pkg/queue/handler.go').read_text()+Path('cmd/worker/main.go').read_text()
|
||||
registered={'constants.'+name for name in re.findall(r'(?:HandleFunc|Register)\(constants\.(TaskType\w+|OutboxEventType\w+)', worker_source)}
|
||||
matrix_async={row['entry'] for row in matrix if row['entry_type']=='async'}
|
||||
if matrix_async != registered:
|
||||
raise SystemExit('异步注册入口与证据矩阵双向覆盖不一致')
|
||||
PY
|
||||
|
||||
tmpdir=$(mktemp -d)
|
||||
generated=docs/admin-openapi.yaml
|
||||
had_generated=0
|
||||
if [[ -f "$generated" ]]; then
|
||||
had_generated=1
|
||||
cp "$generated" "$tmpdir/original.yaml"
|
||||
fi
|
||||
cleanup_generated() {
|
||||
if (( had_generated )); then cp "$tmpdir/original.yaml" "$generated"; else rm -f "$generated"; fi
|
||||
rm -rf "$tmpdir"
|
||||
}
|
||||
trap cleanup_generated EXIT
|
||||
go run cmd/gendocs/main.go >/dev/null 2>&1
|
||||
cp "$generated" "$tmpdir/first.yaml"
|
||||
go run cmd/gendocs/main.go >/dev/null 2>&1
|
||||
cmp -s "$tmpdir/first.yaml" "$generated" || { print -u2 'OpenAPI 连续生成结果不一致'; exit 1; }
|
||||
|
||||
print 'Context 健康检查通过'
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# 本验收只运行公共基础拥有的公开接缝;真实依赖连接信息沿用项目本地配置。
|
||||
if [[ -f .env.local ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1091
|
||||
source .env.local
|
||||
set +a
|
||||
fi
|
||||
|
||||
GOCACHE="${GOCACHE:-/tmp/junhong-go-cache}" go test \
|
||||
./pkg/idempotency \
|
||||
./pkg/asynctask \
|
||||
./pkg/logger \
|
||||
./pkg/queue \
|
||||
./internal/infrastructure/asynctask \
|
||||
./internal/infrastructure/releasegate \
|
||||
./internal/infrastructure/messaging/outbox \
|
||||
./internal/query/outbox \
|
||||
./internal/application/outbox \
|
||||
./internal/infrastructure/systemconfig \
|
||||
./internal/query/systemconfig \
|
||||
./internal/application/systemconfig \
|
||||
./internal/routes \
|
||||
-count=1
|
||||
Reference in New Issue
Block a user