重置项目上下文与规范文档

This commit is contained in:
2026-08-07 16:18:07 +08:00
parent 6611ca5226
commit 79e2d9ff92
1900 changed files with 1552 additions and 348365 deletions

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 响应时间

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
View 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 健康检查通过'

View File

@@ -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