This commit is contained in:
2026-11-04 15:52:50 +08:00
parent 62291c57ec
commit cc66e98f61
6 changed files with 41 additions and 26 deletions
+5 -4
View File
@@ -39,10 +39,11 @@ uv run ruff check .
`updataTime` types remain number for realtime and string for official. `updataTime` types remain number for realtime and string for official.
Redis locks prevent duplicate work across workers. Realtime region pages stay Redis locks prevent duplicate work across workers. Realtime region pages stay
8 seconds apart. Failures retain the last successful data for up to 24 hours. 8 seconds apart. Failures retain the last successful data for up to 24 hours.
- Realtime uses the arithmetic mean of displayed unit prices from rows 4–10 - Realtime selects rows 4–10 (slice `[3:10]`), converts each original unit price
(`i.item1`, slice `[3:10]`), in 元/万金币, rounded to 2 decimals. This replaces with `1 / Price`, then averages those ratios in 万金币/元. Rounding to 2 decimals
the earlier 万金币/元 average and uses a new Redis namespace so old units are happens only after averaging; prices are not rounded before conversion. A new
never returned as new prices. No BeautifulSoup or browser dependency is added. Redis namespace excludes the earlier 元/万金币 cache, whose rounded prices
cannot recover the original ratios. No new dependency is added.
- Official authentication uses `HELPER_TOKEN` and `HELPER_ID`. Authentication - Official authentication uses `HELPER_TOKEN` and `HELPER_ID`. Authentication
failures (`returnCode=-30003`, explicit token-expiry messages or HTTP 401) can failures (`returnCode=-30003`, explicit token-expiry messages or HTTP 401) can
notify via Bark. Network errors do not trigger token-expiry notifications. notify via Bark. Network errors do not trigger token-expiry notifications.
+3 -3
View File
@@ -41,7 +41,7 @@ def get_date_str(timestamp: int) -> str:
@router.get('/gold/realtime/', operation_id='realtimeGold', response_model=Response[list[RealTimeGold]]) @router.get('/gold/realtime/', operation_id='realtimeGold', response_model=Response[list[RealTimeGold]])
async def get_real_time_gold(redis: Annotated[AsyncRedis, Depends(thirdpart_gold.get_redis)], background_tasks: BackgroundTasks) -> Response[list[RealTimeGold]]: async def get_real_time_gold(redis: Annotated[AsyncRedis, Depends(thirdpart_gold.get_redis)], background_tasks: BackgroundTasks) -> Response[list[RealTimeGold]]:
"""获取网站金币单价,取页面第4~10条单价均值,元/万金币,两位小数。 """获取网站金币比例,取页面第4~10条的平均比例,万金币/元,两位小数。
updataTime为数据所属半小时刻度的数字秒级时间戳,:25/:55提前采集对应:30/:00数据。与thirdpart接口共用Redis缓存和 updataTime为数据所属半小时刻度的数字秒级时间戳,:25/:55提前采集对应:30/:00数据。与thirdpart接口共用Redis缓存和
后台刷新任务:30分钟自动更新,也支持按需补齐,逐区间隔8秒;初始化未齐时响应体code=202。 后台刷新任务:30分钟自动更新,也支持按需补齐,逐区间隔8秒;初始化未齐时响应体code=202。
@@ -145,10 +145,10 @@ async def get_official_gold(redis: Annotated[AsyncRedis, Depends(thirdpart_gold.
@router.get('/gold/thirdpart/', operation_id='thirdpartGold', response_model=Response[list[RealTimeGold]]) @router.get('/gold/thirdpart/', operation_id='thirdpartGold', response_model=Response[list[RealTimeGold]])
async def get_thirdpart_gold(redis: Annotated[AsyncRedis, Depends(thirdpart_gold.get_redis)], background_tasks: BackgroundTasks) -> Response[list[RealTimeGold]]: async def get_thirdpart_gold(redis: Annotated[AsyncRedis, Depends(thirdpart_gold.get_redis)], background_tasks: BackgroundTasks) -> Response[list[RealTimeGold]]:
"""获取跨1~跨8(含跨3A/3B)的第三方单价均值,单位:元/万金币。 """获取跨1~跨8(含跨3A/3B)的第三方平均比例,单位:万金币/元。
不额外限制最低总价;汇总渠道报价并按公开页面展示规则排序、去重, 不额外限制最低总价;汇总渠道报价并按公开页面展示规则排序、去重,
对i.item1的第4~10条(Python切片[3:10])显示单价取算术平均, 对第4~10条(Python切片[3:10])原始单价逐条计算1/Price后取算术平均,
四舍五入至两位小数,不按库存或份数加权。 四舍五入至两位小数,不按库存或份数加权。
每30分钟自动后台刷新,跨区页面最短间隔8秒(每分钟不超过8次),Redis锁避免重复刷新。 每30分钟自动后台刷新,跨区页面最短间隔8秒(每分钟不超过8次),Redis锁避免重复刷新。
+1 -1
View File
@@ -15,7 +15,7 @@ class RealTimeGold(BaseModel):
region: str = Field(..., description='跨区') region: str = Field(..., description='跨区')
"""跨区""" """跨区"""
code: str = Field(..., description='跨区code:k1、k2、k3a、k3b、k4、k5、k6、k7、k8') code: str = Field(..., description='跨区code:k1、k2、k3a、k3b、k4、k5、k6、k7、k8')
goldPrice: float = Field(..., ge=0, allow_inf_nan=False, description='页面第4~10条单价均值,元/万金币,四舍五入至两位小数') goldPrice: float = Field(..., ge=0, allow_inf_nan=False, description='页面第4~10条报价分别换算后求均值,1元对应的万金币数量,两位小数')
"""金价""" """金价"""
@field_validator('updataTime') @field_validator('updataTime')
+9 -7
View File
@@ -45,8 +45,8 @@ REGION_INTERVAL = 8
LOCK_SECONDS = 15 * 60 LOCK_SECONDS = 15 * 60
ERROR_COOLDOWN_SECONDS = 5 * 60 ERROR_COOLDOWN_SECONDS = 5 * 60
REDIS_TIMEOUT_SECONDS = 1 REDIS_TIMEOUT_SECONDS = 1
# 统计范围和单位已改变,旧缓存的万金币/元均值不能作为新的元/万金币单价返回。 # v2缓存已将元/万金币单价舍入至0.01,无法还原比例,改用新缓存重新采集。
CACHE_PREFIX = 'openapi:colg:thirdpart_gold:v2' CACHE_PREFIX = 'openapi:colg:thirdpart_gold:v3'
LOCK_KEY = f'{CACHE_PREFIX}:lock' LOCK_KEY = f'{CACHE_PREFIX}:lock'
COOLDOWN_KEY = f'{CACHE_PREFIX}:cooldown' COOLDOWN_KEY = f'{CACHE_PREFIX}:cooldown'
RELEASE_LOCK = """ RELEASE_LOCK = """
@@ -128,7 +128,7 @@ def make_payload(channel: dict, seed: dict, user: dict) -> dict:
def first_page_average(offers: list[dict]) -> tuple[float, int]: def first_page_average(offers: list[dict]) -> tuple[float, int]:
"""匹配公开页面 i.item1 的第4~10条,平均显示单价(元/万金币)。""" """取页面第4~10条报价,逐条按1/Price换算后平均,单位万金币/元。"""
valid = [] valid = []
for offer in offers: for offer in offers:
try: try:
@@ -137,7 +137,9 @@ def first_page_average(offers: list[dict]) -> tuple[float, int]:
number = float(offer['Number']) number = float(offer['Number'])
except (KeyError, TypeError, ValueError, OverflowError): except (KeyError, TypeError, ValueError, OverflowError):
continue continue
if not all(math.isfinite(value) for value in (price, money, number)) or price < 0: if not all(math.isfinite(value) for value in (price, money, number)) or price <= 0:
continue
if not math.isfinite(1 / price):
continue continue
# 页面默认展示条件,不额外设置price1/price2价格过滤。 # 页面默认展示条件,不额外设置price1/price2价格过滤。
if money != -1 and (money * number < 5 or price == 0): if money != -1 and (money * number < 5 or price == 0):
@@ -160,9 +162,9 @@ def first_page_average(offers: list[dict]) -> tuple[float, int]:
selected = page[SAMPLE_SLICE] selected = page[SAMPLE_SLICE]
if not selected: if not selected:
raise ValueError('YXDR 未返回有效报价') raise ValueError('YXDR 未返回有效报价')
# 页面 Math.floorDigits(price, 5) 先toFixed(6)再删掉末位。 # 对原始单价取倒数后求均值,最后才保留两位,避免先舍入单价丢失比例差异。
prices = [float(f"{item['Price']:.6f}"[:-1]) for item in selected] ratios = [1 / item['Price'] for item in selected]
return round(fmean(prices), 2), len(prices) return round(fmean(ratios), 2), len(ratios)
async def _read_response(resp) -> str: async def _read_response(resp) -> str:
+2 -2
View File
@@ -25,8 +25,8 @@ def test_refresh_slot_advances_five_minutes_before_boundary(hour, minute, second
def test_response_types_round_price_values_and_keep_timestamp_types(): def test_response_types_round_price_values_and_keep_timestamp_types():
realtime = RealTimeGold(updataTime=stamp(1, 30), region='跨1', code='k1', goldPrice=.016789) realtime = RealTimeGold(updataTime=stamp(1, 30), region='跨1', code='k1', goldPrice=105.26315789)
assert realtime.goldPrice == .02 assert realtime.goldPrice == 105.26
assert realtime.updataTime == stamp(1, 30) assert realtime.updataTime == stamp(1, 30)
assert isinstance(realtime.updataTime, int) assert isinstance(realtime.updataTime, int)
official = OfficialGold(updataTime=str(stamp(1, 30)), region='跨1', code='k1', official = OfficialGold(updataTime=str(stamp(1, 30)), region='跨1', code='k1',
+21 -9
View File
@@ -18,7 +18,7 @@ def offer(price, url='listing', platform=17, money=10):
def cached_item(region='跨1', age=0): def cached_item(region='跨1', age=0):
fetched_at = time.time() - age fetched_at = time.time() - age
return {'region': region, 'goldPrice': .01, 'sampleCount': 7, return {'region': region, 'goldPrice': 100.0, 'sampleCount': 7,
'code': GOLD_REGION_CODES[region], 'updataTime': refresh_slot(fetched_at), 'code': GOLD_REGION_CODES[region], 'updataTime': refresh_slot(fetched_at),
'fetchedAt': fetched_at} 'fetchedAt': fetched_at}
@@ -54,16 +54,16 @@ class FakeRedis:
return 0 return 0
def test_average_uses_displayed_unit_prices_at_indices_three_through_nine(): def test_average_uses_gold_per_yuan_ratios_at_indices_three_through_nine():
rows = [offer(i / 100) for i in range(1, 12)] rows = [offer(i / 100) for i in range(1, 12)]
assert gold.first_page_average(rows) == (.07, 7) assert gold.first_page_average(rows) == (15.65, 7)
invalid = [offer(0), offer(-1), offer(float('nan')), offer(float('inf')), invalid = [offer(0), offer(-1), offer(float('nan')), offer(float('inf')),
offer(.01, money=-1), offer(None), offer(.01, money=float('nan'))] offer(.01, money=-1), offer(None), offer(.01, money=float('nan'))]
# 页面允许Money=-1的库存占位报价;另行排除不显示的低额订单。 # 页面允许Money=-1的库存占位报价;另行排除不显示的低额订单。
invalid.pop(4) invalid.pop(4)
assert gold.first_page_average(invalid + [offer(.01, money=.5)] + rows) == (.07, 7) assert gold.first_page_average(invalid + [offer(.01, money=.5)] + rows) == (15.65, 7)
assert gold.first_page_average([offer(.016789)] * 10) == (.02, 7) assert gold.first_page_average([offer(.016789)] * 10) == (round(1 / .016789, 2), 7)
assert gold.first_page_average(rows[:4]) == (.04, 1) assert gold.first_page_average(rows[:4]) == (25.0, 1)
with pytest.raises(ValueError, match='有效报价'): with pytest.raises(ValueError, match='有效报价'):
gold.first_page_average(invalid) gold.first_page_average(invalid)
with pytest.raises(ValueError, match='有效报价'): with pytest.raises(ValueError, match='有效报价'):
@@ -73,9 +73,15 @@ def test_average_uses_displayed_unit_prices_at_indices_three_through_nine():
def test_page_deduplicates_before_cutoff_and_preserves_7881_variants(): def test_page_deduplicates_before_cutoff_and_preserves_7881_variants():
rows = [offer(.01, 'duplicate', 40), offer(.01, 'duplicate', 40)] rows = [offer(.01, 'duplicate', 40), offer(.01, 'duplicate', 40)]
rows += [offer(i / 100, str(i), 17) for i in range(2, 12)] rows += [offer(i / 100, str(i), 17) for i in range(2, 12)]
assert gold.first_page_average(rows) == (.07, 7) assert gold.first_page_average(rows) == (15.65, 7)
rows = [offer(.01, platform=44)] * 3 + [offer(.04, platform=44)] * 7 rows = [offer(.01, platform=44)] * 3 + [offer(.04, platform=44)] * 7
assert gold.first_page_average(rows) == (.04, 7) assert gold.first_page_average(rows) == (25.0, 7)
def test_each_quote_is_inverted_before_averaging_without_early_rounding():
rows = [offer(.008)] * 3 + [offer(.0095)] * 4 + [offer(.01)] * 3
assert gold.first_page_average(rows) == (103.01, 7)
assert round(1 / ((.0095 * 4 + .01 * 3) / 7), 2) == 102.94
def test_request_signature_matches_observed_browser_request(): def test_request_signature_matches_observed_browser_request():
@@ -135,7 +141,7 @@ def test_region_fetches_its_own_seed_config_and_all_initial_channels():
assert result['region'] == '跨8' assert result['region'] == '跨8'
assert result['code'] == 'k8' assert result['code'] == 'k8'
assert isinstance(result['updataTime'], int) assert isinstance(result['updataTime'], int)
assert result['goldPrice'] == .01 assert result['goldPrice'] == 100.0
assert result['sampleCount'] == 7 assert result['sampleCount'] == 7
assert result['updataTime'] == refresh_slot(started_at) assert result['updataTime'] == refresh_slot(started_at)
assert result['fetchedAt'] == started_at assert result['fetchedAt'] == started_at
@@ -184,6 +190,12 @@ def test_invalid_cached_average_is_not_served():
assert asyncio.run(gold.read_cache(redis)) == [] assert asyncio.run(gold.read_cache(redis)) == []
def test_old_yuan_per_gold_cache_cannot_be_returned_as_new_ratio():
redis = FakeRedis()
redis.values['openapi:colg:thirdpart_gold:v2:kua1'] = json.dumps({**cached_item(), 'goldPrice': .01})
assert asyncio.run(gold.read_cache(redis)) == []
def test_legacy_cache_gets_epoch_and_region_code_without_refetching(): def test_legacy_cache_gets_epoch_and_region_code_without_refetching():
redis = FakeRedis() redis = FakeRedis()
legacy = cached_item('跨3A') legacy = cached_item('跨3A')