This commit is contained in:
macbook_max
2026-10-15 20:36:59 +08:00
commit 6bf949949c
7923 changed files with 1369582 additions and 0 deletions
+114
View File
@@ -0,0 +1,114 @@
"""测试基础设施:内存 SQLite(跳过索引)+ 内存 FakeRedis + 敏感词进程缓存复位"""
import pytest
from sqlalchemy import schema
from sqlalchemy.dialects import mysql
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.ext.compiler import compiles
import app.models # noqa: F401 注册全部模型
from app.models.base import Base
from app.services import sensitive_service
@compiles(mysql.BIGINT, "sqlite")
def _bigint_as_integer(element, compiler, **kw):
# MySQL BIGINT UNSIGNED 在 SQLite 渲染为 INTEGER,主键才能成为 rowid 别名(自增可用)
return "INTEGER"
@compiles(mysql.INTEGER, "sqlite")
def _int_as_integer(element, compiler, **kw):
return "INTEGER"
class FakeRedis:
"""内存版 Redis 桩:覆盖搜索/敏感词/配置/限流用到的命令子集"""
def __init__(self):
self.kv: dict[str, str] = {}
self.hashes: dict[str, dict[str, str]] = {}
self.zsets: dict[str, dict[str, float]] = {}
self.ttls: dict[str, int] = {}
async def get(self, key):
return self.kv.get(key)
async def set(self, key, value, ex=None, nx=False):
if nx and key in self.kv:
return None
self.kv[key] = str(value)
if ex is not None:
self.ttls[key] = ex
return True
async def setex(self, key, ex, value):
self.kv[key] = str(value)
self.ttls[key] = ex
return True
async def delete(self, *keys):
for key in keys:
self.kv.pop(key, None)
self.hashes.pop(key, None)
self.zsets.pop(key, None)
return len(keys)
async def expire(self, key, ttl):
self.ttls[key] = ttl
return True
async def incr(self, key):
self.kv[key] = str(int(self.kv.get(key, "0")) + 1)
return int(self.kv[key])
async def hset(self, key, mapping=None, **kw):
items = mapping or kw
self.hashes.setdefault(key, {}).update({str(k): str(v) for k, v in items.items()})
return len(items)
async def hgetall(self, key):
return dict(self.hashes.get(key, {}))
async def hget(self, key, field):
return self.hashes.get(key, {}).get(field)
async def zadd(self, key, mapping):
self.zsets.setdefault(key, {}).update({str(m): float(s) for m, s in mapping.items()})
return len(mapping)
async def zincrby(self, key, amount, member):
z = self.zsets.setdefault(key, {})
z[str(member)] = z.get(str(member), 0.0) + float(amount)
return z[str(member)]
async def zrevrange(self, key, start, stop):
items = sorted(self.zsets.get(key, {}).items(), key=lambda kv: kv[1], reverse=True)
members = [m for m, _ in items]
if stop < 0:
stop = len(members) + stop # Redis 负索引语义
return members[start:stop + 1]
@pytest.fixture
def redis():
return FakeRedis()
@pytest.fixture(autouse=True)
def _reset_sensitive_cache():
# 敏感词匹配器进程缓存 60s,测试间必须复位
sensitive_service._local_cache = None
yield
sensitive_service._local_cache = None
@pytest.fixture
async def db():
"""内存 SQLite 会话:只建表不建索引(模型存在跨表重名索引,SQLite 索引名全局唯一)"""
eng = create_async_engine("sqlite+aiosqlite:///:memory:")
async with eng.begin() as conn:
await conn.run_sync(lambda c: [c.execute(schema.CreateTable(t)) for t in Base.metadata.sorted_tables])
Session = async_sessionmaker(eng, expire_on_commit=False)
async with Session() as s:
yield s
await eng.dispose()
+139
View File
@@ -0,0 +1,139 @@
"""推广归因测试:点击埋点 → 首启模糊匹配 → 注册一次性消费(全链路离线可验证)"""
import pytest
from app.services import invite_service
class FakeRedis:
"""内存版 Redis 桩:仅实现归因模块用到的命令子集"""
def __init__(self):
self.kv: dict[str, str] = {}
self.zsets: dict[str, dict[str, float]] = {}
self.ttls: dict[str, int] = {}
async def set(self, key, value, nx=False, ex=None):
if nx and key in self.kv:
return None
self.kv[key] = str(value)
if ex is not None:
self.ttls[key] = ex
return True
async def get(self, key):
return self.kv.get(key)
async def delete(self, key):
self.kv.pop(key, None)
self.zsets.pop(key, None)
async def expire(self, key, ttl):
self.ttls[key] = ttl
return True
async def zadd(self, key, mapping):
self.zsets.setdefault(key, {}).update({m: float(s) for m, s in mapping.items()})
return len(mapping)
async def zrevrange(self, key, start, stop):
items = sorted(self.zsets.get(key, {}).items(), key=lambda kv: kv[1], reverse=True)
members = [m for m, _ in items]
if stop < 0:
stop = len(members) + stop # Redis 负索引语义:-1 为最后一个元素
return members[start:stop + 1]
@pytest.fixture
def redis():
return FakeRedis()
IP = "203.0.113.8"
UA = "Mozilla/5.0 (Linux; Android 14)"
CODE_A, CODE_B = "AAAA2222", "BBBB3333"
DEVICE = "dev-uuid-0001"
# ---------- 点击埋点 ----------
@pytest.mark.asyncio
async def test_click_writes_ip_zset_and_detail(redis):
r = await invite_service.record_click(redis, CODE_A, IP, UA)
assert r == {"recorded": True}
assert await redis.zrevrange(invite_service._ip_key(IP), 0, -1) == [CODE_A]
detail = await redis.get(invite_service._clk_key(CODE_A, IP))
assert detail and UA in detail
# 两个 key 都设置了 24h TTL
assert redis.ttls[invite_service._ip_key(IP)] == invite_service.CLICK_TTL
assert redis.ttls[invite_service._clk_key(CODE_A, IP)] == invite_service.CLICK_TTL
@pytest.mark.asyncio
async def test_click_dedup_within_60s(redis):
await invite_service.record_click(redis, CODE_A, IP, UA)
r2 = await invite_service.record_click(redis, CODE_A, IP, UA)
assert r2 == {"recorded": True, "dedup": True}
assert len(redis.zsets[invite_service._ip_key(IP)]) == 1
@pytest.mark.asyncio
async def test_click_without_ip_not_recorded(redis):
assert (await invite_service.record_click(redis, CODE_A, "", UA)) == {"recorded": False}
# ---------- 归因匹配 ----------
@pytest.mark.asyncio
async def test_match_returns_clicked_code(redis):
await invite_service.record_click(redis, CODE_A, IP, UA)
r = await invite_service.match_and_cache(redis, DEVICE, IP, UA)
assert r == {"matched": True, "invite_code": CODE_A}
@pytest.mark.asyncio
async def test_match_most_recent_click_wins(redis):
await invite_service.record_click(redis, CODE_A, IP, UA)
await invite_service.record_click(redis, CODE_B, IP, UA) # 时间更晚
r = await invite_service.match_and_cache(redis, DEVICE, IP, UA)
assert r["invite_code"] == CODE_B
@pytest.mark.asyncio
async def test_match_no_click_unmatched(redis):
assert (await invite_service.match_and_cache(redis, DEVICE, IP, UA)) == {"matched": False}
@pytest.mark.asyncio
async def test_match_skips_candidate_missing_detail(redis):
"""zset 中有 code 但点击详情已过期 → 跳到下一候选"""
await invite_service.record_click(redis, CODE_A, IP, UA)
await invite_service.record_click(redis, CODE_B, IP, UA)
await redis.delete(invite_service._clk_key(CODE_B, IP)) # 详情丢失
r = await invite_service.match_and_cache(redis, DEVICE, IP, UA)
assert r["invite_code"] == CODE_A
@pytest.mark.asyncio
async def test_match_idempotent_via_cache(redis):
await invite_service.record_click(redis, CODE_A, IP, UA)
await invite_service.match_and_cache(redis, DEVICE, IP, UA)
# 点击记录清除后,归因缓存仍命中(同设备重复首启不重复计算)
await redis.delete(invite_service._ip_key(IP))
r = await invite_service.match_and_cache(redis, DEVICE, IP, UA)
assert r == {"matched": True, "invite_code": CODE_A}
# ---------- 注册消费 ----------
@pytest.mark.asyncio
async def test_pop_attribution_one_time(redis):
await invite_service.record_click(redis, CODE_A, IP, UA)
await invite_service.match_and_cache(redis, DEVICE, IP, UA)
assert await invite_service.pop_attribution(redis, DEVICE) == CODE_A
# 一次性消费:第二次取不到
assert await invite_service.pop_attribution(redis, DEVICE) is None
@pytest.mark.asyncio
async def test_pop_attribution_empty(redis):
assert await invite_service.pop_attribution(redis, "never-matched") is None
+216
View File
@@ -0,0 +1,216 @@
"""支付渠道加密能力测试:RSA2 签名/验签与微信 V3 AES-GCM 解密均为离线可验证。
真实商户密钥接入前,用自签密钥对完成「本地签名 → 验签」往返,确保:
- 支付宝 order_string 可被支付宝公钥验签通过(签名串构造正确)
- 回调验签能正确拒绝篡改报文
- 微信 V3 回调解密可还原明文
"""
import base64
import json
import uuid
from urllib.parse import parse_qs
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from app.config import settings
from app.payment import alipay, base, wechat
@pytest.fixture(scope="module")
def keypair():
"""生成一对 RSA 密钥(模拟商户私钥 / 平台公钥)"""
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
priv = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
pub = key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode()
return priv, pub
def _pem_body(pem: str, label: str) -> str:
"""去掉 PEM 头尾与换行,得到裸 base64(支付宝密钥常见下发形式)"""
body = "\n".join(
line for line in pem.splitlines() if not line.startswith("-----")
)
return body.replace("\n", "")
# ---------- base:密钥加载 / 签名原语 ----------
def test_load_private_key_accepts_pem_and_bare_base64(keypair):
priv, _ = keypair
assert base.load_private_key(priv) is not None
# 裸 base64(PKCS8)也应可加载
assert base.load_private_key(_pem_body(priv, "PRIVATE KEY")) is not None
def test_load_public_key_accepts_pem(keypair):
_, pub = keypair
assert base.load_public_key(pub) is not None
def test_load_private_key_empty_raises():
with pytest.raises(base.PaymentConfigError):
base.load_private_key("")
def test_rsa2_sign_verify_roundtrip(keypair):
priv, pub = keypair
content = "app_id=2021000000&method=alipay.trade.app.pay&version=1.0"
signature = base.rsa2_sign(content, priv)
assert base.rsa2_verify(content, signature, pub) is True
# 篡改内容必须验签失败
assert base.rsa2_verify(content + "&x=1", signature, pub) is False
# 篡改签名必须验签失败(非法 base64 也不得抛异常)
assert base.rsa2_verify(content, "not-a-signature", pub) is False
def test_build_sign_content_sorts_and_filters():
params = {"b": "2", "a": "1", "sign": "xxx", "sign_type": "RSA2", "empty": ""}
assert base.build_sign_content(params) == "a=1&b=2"
# ---------- 支付宝 ----------
def test_alipay_dev_mock_when_not_configured(monkeypatch):
monkeypatch.setattr(settings, "alipay_app_id", "")
result = alipay.create_app_pay_params("R123", 100)
assert result["dev_mock"] is True
def test_alipay_order_string_signable_and_verifiable(monkeypatch, keypair):
priv, pub = keypair
monkeypatch.setattr(settings, "alipay_app_id", "2021000000000000")
monkeypatch.setattr(settings, "alipay_private_key", priv)
monkeypatch.setattr(settings, "alipay_public_key", pub)
result = alipay.create_app_pay_params("R20261015001", 1234)
assert "dev_mock" not in result
params = {k: v[0] for k, v in parse_qs(result["order_string"]).items()}
# 关键参数齐备
assert params["app_id"] == "2021000000000000"
assert params["method"] == alipay.METHOD_APP_PAY
assert params["sign_type"] == "RSA2"
# 金额按分转元、两位小数
biz = json.loads(params["biz_content"])
assert biz["total_amount"] == "12.34"
assert biz["out_trade_no"] == "R20261015001"
# order_string 中的参数可被公钥验签通过(即签名串构造正确)
assert base.rsa2_verify(base.build_sign_content(params), params["sign"], pub) is True
def test_alipay_verify_callback_accepts_valid_and_rejects_tampered(monkeypatch, keypair):
priv, pub = keypair
monkeypatch.setattr(settings, "alipay_public_key", pub)
form = {
"out_trade_no": "R20261015001",
"trade_no": "2026101522001400000001",
"trade_status": "TRADE_SUCCESS",
"total_amount": "12.34",
}
form["sign"] = base.rsa2_sign(base.build_sign_content(form), priv)
form["sign_type"] = "RSA2"
assert alipay.verify_callback(form) is True
# 篡改金额 → 验签失败
assert alipay.verify_callback({**form, "total_amount": "0.01"}) is False
# 缺少 sign → 拒绝
form.pop("sign")
assert alipay.verify_callback(form) is False
# ---------- 微信 V3 ----------
def test_wechat_dev_mock_when_not_configured(monkeypatch):
monkeypatch.setattr(settings, "wechat_mchid", "")
import asyncio
result = asyncio.run(wechat.create_app_pay_params("R123", 100))
assert result["dev_mock"] is True
def test_wechat_app_params_second_sign_verifiable(monkeypatch, keypair):
"""未接入网络前,直接验证 App 调起参数的二次签名逻辑"""
import asyncio
priv, pub = keypair
monkeypatch.setattr(settings, "wechat_mchid", "1900000001")
monkeypatch.setattr(settings, "wechat_app_id", "wx1234567890")
monkeypatch.setattr(settings, "wechat_api_v3_key", "k" * 32)
monkeypatch.setattr(settings, "wechat_private_key", priv)
async def fake_prepay(order_no, amount_fen):
return "prepay_test_123"
monkeypatch.setattr(wechat, "_create_prepay_id", fake_prepay)
params = asyncio.run(wechat.create_app_pay_params("R20261015001", 1234))
assert params["appid"] == "wx1234567890"
assert params["partnerid"] == "1900000001"
assert params["prepayid"] == "prepay_test_123"
assert params["package"] == "Sign=WXPay"
sign_str = (
f"{params['appid']}\n{params['partnerid']}\n{params['prepayid']}\n"
f"{params['package']}\n{params['noncestr']}\n{params['timestamp']}\n"
)
assert base.rsa2_verify(sign_str, params["sign"], pub) is True
def test_wechat_verify_callback_roundtrip(monkeypatch, keypair):
"""构造一次完整回调:平台私钥签名 + AES-GCM 加密 → 验签解密还原明文"""
priv, pub = keypair
api_v3_key = "0123456789abcdef0123456789abcdef"
monkeypatch.setattr(settings, "wechat_mchid", "1900000001")
monkeypatch.setattr(settings, "wechat_app_id", "wx1234567890")
monkeypatch.setattr(settings, "wechat_api_v3_key", api_v3_key)
monkeypatch.setattr(settings, "wechat_private_key", priv)
monkeypatch.setattr(settings, "wechat_platform_cert", pub)
plain = {
"out_trade_no": "R20261015001",
"transaction_id": "4200001234202610150001",
"trade_state": "SUCCESS",
"amount": {"total": 1234, "currency": "CNY"},
}
nonce = uuid.uuid4().hex[:12]
ciphertext = AESGCM(api_v3_key.encode()).encrypt(
nonce.encode(), json.dumps(plain).encode(), b"transaction"
)
body = json.dumps({
"resource": {
"algorithm": "AEAD_AES_256_GCM",
"ciphertext": base64.b64encode(ciphertext).decode(),
"nonce": nonce,
"associated_data": "transaction",
}
}).encode()
timestamp, header_nonce = "1792000000", uuid.uuid4().hex
message = f"{timestamp}\n{header_nonce}\n{body.decode()}\n"
headers = {
"wechatpay-timestamp": timestamp,
"wechatpay-nonce": header_nonce,
"wechatpay-signature": base.rsa2_sign(message, priv),
}
result = wechat.verify_callback(headers, body)
assert result is not None
assert result["out_trade_no"] == "R20261015001"
assert result["amount"]["total"] == 1234
# 篡改 timestamp → 验签失败
assert wechat.verify_callback({**headers, "wechatpay-timestamp": "1"}, body) is None
def test_wechat_decrypt_rejects_bad_ciphertext():
assert base.wechat_decrypt_resource({"ciphertext": "!!", "nonce": "abc"}, "k" * 32) is None
+144
View File
@@ -0,0 +1,144 @@
"""后台退款(积分+返利资产扣回)与 iOS 支付开关"""
import uuid
from datetime import datetime, timedelta
from decimal import Decimal
from types import SimpleNamespace
import pytest
from sqlalchemy import select
from app.core.response import BizException
from app.models.admin import AdminLog
from app.models.billing import RechargeOrder
from app.models.common import SystemConfig
from app.models.incentive import AssetTransaction, AssetWallet
from app.models.user import User, UserWallet
from app.services import admin_service, payment_service
async def _user(db, *, points=0):
u = User(password_hash="x", register_type=1, invite_code=uuid.uuid4().hex[:8].upper())
db.add(u)
await db.flush()
db.add(UserWallet(user_id=u.id, points=points))
await db.commit()
return u
async def _paid_order(db, user_id, order_no="R20261015001", *, status=1, points=100):
o = RechargeOrder(
order_no=order_no, user_id=user_id, amount_fen=1000, points=points,
pay_method=1, status=status, paid_at=datetime.now(),
expire_at=datetime.now() + timedelta(minutes=30),
)
db.add(o)
await db.commit()
return o
def _admin():
return SimpleNamespace(admin_id=1, role="super")
# ---------- 退款 ----------
@pytest.mark.asyncio
async def test_refund_claws_back_points_and_rebate(db):
buyer = await _user(db, points=150)
inviter = await _user(db)
db.add(AssetWallet(user_id=inviter.id, assets=Decimal(5)))
db.add(AssetTransaction(
user_id=inviter.id, change_amount=Decimal(5), balance_after=Decimal(5),
biz_type=1, biz_id="R20261015001", remark="充值返利",
))
order = await _paid_order(db, buyer.id)
r = await admin_service.refund_order(
db, _admin(), order.order_no, SimpleNamespace(reason="用户申请退款"), "127.0.0.1"
)
assert r["order_no"] == order.order_no
await db.refresh(order)
assert order.status == 3 # 已退款
# 积分扣回(biz_type=7)
assert (await db.get(UserWallet, buyer.id)).points == 50
# 返利资产扣回(biz_type=6 反向流水)
assert (await db.get(AssetWallet, inviter.id)).assets == Decimal(0)
clawback = (await db.execute(select(AssetTransaction).where(
AssetTransaction.biz_type == 6, AssetTransaction.biz_id == order.order_no
))).scalar_one()
assert clawback.change_amount == Decimal(-5)
# 操作日志落库
log = (await db.execute(select(AdminLog).where(AdminLog.action == "order_refund"))).scalar_one()
assert log.target == order.order_no
@pytest.mark.asyncio
async def test_refund_repeat_rejected(db):
buyer = await _user(db, points=150)
order = await _paid_order(db, buyer.id)
await admin_service.refund_order(db, _admin(), order.order_no, SimpleNamespace(reason="退款"), "")
with pytest.raises(BizException) as exc:
await admin_service.refund_order(db, _admin(), order.order_no, SimpleNamespace(reason="再退"), "")
assert exc.value.code == 3002 # ORDER_STATUS_ERROR
@pytest.mark.asyncio
async def test_refund_unpaid_order_rejected(db):
buyer = await _user(db)
order = await _paid_order(db, buyer.id, order_no="R_UNPAID", status=0)
with pytest.raises(BizException) as exc:
await admin_service.refund_order(db, _admin(), order.order_no, SimpleNamespace(reason="退款"), "")
assert exc.value.code == 3002
@pytest.mark.asyncio
async def test_refund_not_found(db):
with pytest.raises(BizException) as exc:
await admin_service.refund_order(db, _admin(), "NO_SUCH", SimpleNamespace(reason="退款"), "")
assert exc.value.code == 3001 # ORDER_NOT_FOUND
@pytest.mark.asyncio
async def test_refund_points_can_go_negative(db):
"""积分已被消费时扣回允许余额为负(对账兜底)"""
buyer = await _user(db, points=30)
order = await _paid_order(db, buyer.id) # 该单发了 100 积分
await admin_service.refund_order(db, _admin(), order.order_no, SimpleNamespace(reason="退款"), "")
assert (await db.get(UserWallet, buyer.id)).points == -70
# ---------- iOS 支付开关 ----------
def _recharge_req():
return SimpleNamespace(amount_yuan=6, pay_method=3) # USDT 渠道(离线可建单)
@pytest.mark.asyncio
async def test_ios_blocked_when_switch_off(db, redis):
db.add(SystemConfig(config_key="ios_payment.enabled", config_value="0"))
await db.commit()
with pytest.raises(BizException) as exc:
await payment_service.create_recharge(db, redis, 1, _recharge_req(), platform="ios")
assert "iOS" in exc.value.message
@pytest.mark.asyncio
async def test_android_not_affected_by_ios_switch(db, redis):
db.add(SystemConfig(config_key="ios_payment.enabled", config_value="0"))
await db.commit()
r = await payment_service.create_recharge(db, redis, 1, _recharge_req(), platform="android")
assert r["order_no"] and r["pay_method"] == 3
@pytest.mark.asyncio
async def test_ios_allowed_when_switch_on(db, redis):
db.add(SystemConfig(config_key="ios_payment.enabled", config_value="1"))
await db.commit()
r = await payment_service.create_recharge(db, redis, 1, _recharge_req(), platform="ios")
assert r["order_no"]
@pytest.mark.asyncio
async def test_ios_default_allowed_without_config(db, redis):
r = await payment_service.create_recharge(db, redis, 1, _recharge_req(), platform="ios")
assert r["order_no"]
+150
View File
@@ -0,0 +1,150 @@
"""搜索 / 热搜 / 标签视频(5.9 / 5.10 / 5.11):LIKE 匹配、敏感词置空、hot/latest 排序、热搜计数"""
from datetime import datetime, timedelta
from types import SimpleNamespace
import pytest
from app.models.common import SensitiveWord
from app.models.user import User
from app.models.video import Tag, Video, VideoTag
from app.services import video_service
def _identity(user_id: int | None = 1):
return SimpleNamespace(user_id=user_id, device_id=None)
@pytest.fixture
async def author(db):
u = User(password_hash="x", register_type=1, nickname="作者")
db.add(u)
await db.commit()
return u
async def _video(db, user_id, title, *, views=0, likes=0, comments=0,
published_at=None, status=None):
v = Video(
user_id=user_id, title=title,
status=status if status is not None else video_service.STATUS_PUBLISHED,
view_count=views, like_count=likes, comment_count=comments,
published_at=published_at or datetime.now(),
)
db.add(v)
await db.commit()
return v
# ---------- 5.10 视频搜索 ----------
@pytest.mark.asyncio
async def test_search_by_title(db, redis, author):
await _video(db, author.id, "美食教程:红烧肉")
await _video(db, author.id, "旅游日记")
r = await video_service.search_videos(db, redis, _identity(), "美食", "hot", 1, 20)
assert r["total"] == 1
assert "美食" in r["list"][0]["title"]
# 搜索行为计入热搜 ZSet
assert redis.zsets[video_service.HOT_SEARCH_KEY]["美食"] == 1.0
@pytest.mark.asyncio
async def test_search_by_tag(db, redis, author):
v = await _video(db, author.id, "增肌训练记录")
tag = Tag(name="健身")
db.add(tag)
await db.flush()
db.add(VideoTag(video_id=v.id, tag_id=tag.id))
await db.commit()
r = await video_service.search_videos(db, redis, _identity(), "健身", "hot", 1, 20)
assert r["total"] == 1
assert r["list"][0]["video_id"] == v.id
@pytest.mark.asyncio
async def test_search_sensitive_word_returns_empty(db, redis, author):
await _video(db, author.id, "包含违禁词的视频")
db.add(SensitiveWord(word="违禁"))
await db.commit()
r = await video_service.search_videos(db, redis, _identity(), "违禁", "hot", 1, 20)
assert r == {"total": 0, "list": []}
# 敏感词搜索不计入热搜
assert video_service.HOT_SEARCH_KEY not in redis.zsets
@pytest.mark.asyncio
async def test_search_unpublished_not_matched(db, redis, author):
await _video(db, author.id, "待审核美食", status=1)
r = await video_service.search_videos(db, redis, _identity(), "美食", "hot", 1, 20)
assert r["total"] == 0
@pytest.mark.asyncio
async def test_search_hot_and_latest_sort(db, redis, author):
# hot 分 = view×1 + like×5 + comment×10
low = await _video(db, author.id, "测试甲", views=10, likes=0, comments=0) # 10
high = await _video(db, author.id, "测试乙", views=0, likes=3, comments=0) # 15
r = await video_service.search_videos(db, redis, _identity(), "测试", "hot", 1, 20)
assert [i["video_id"] for i in r["list"]] == [high.id, low.id]
old = await _video(db, author.id, "测试旧", published_at=datetime.now() - timedelta(days=2))
r = await video_service.search_videos(db, redis, _identity(), "测试", "latest", 1, 20)
assert r["list"][0]["video_id"] in {high.id, low.id} # 最新发布的在前
assert r["list"][-1]["video_id"] == old.id
@pytest.mark.asyncio
async def test_search_pagination(db, redis, author):
for i in range(5):
await _video(db, author.id, f"分页视频{i}")
r1 = await video_service.search_videos(db, redis, _identity(), "分页", "latest", 1, 2)
r2 = await video_service.search_videos(db, redis, _identity(), "分页", "latest", 2, 2)
assert r1["total"] == 5 and len(r1["list"]) == 2 and len(r2["list"]) == 2
assert {i["video_id"] for i in r1["list"]}.isdisjoint({i["video_id"] for i in r2["list"]})
# ---------- 5.11 热搜榜 ----------
@pytest.mark.asyncio
async def test_hot_search_top10(db, redis):
for i in range(12):
await redis.zincrby(video_service.HOT_SEARCH_KEY, i + 1, f"词{i}")
r = await video_service.get_hot_search(redis)
assert len(r["words"]) == video_service.HOT_SEARCH_TOP
# 按计数倒序:词11 计数最高
assert r["words"][0] == "词11"
assert r["words"][-1] == "词2"
@pytest.mark.asyncio
async def test_hot_search_empty(redis):
assert (await video_service.get_hot_search(redis)) == {"words": []}
# ---------- 5.9 标签视频列表 ----------
@pytest.mark.asyncio
async def test_tag_videos(db, redis, author):
tag = Tag(name="美食")
db.add(tag)
await db.flush()
v1 = await _video(db, author.id, "红烧肉")
v2 = await _video(db, author.id, "清蒸鱼")
v3 = await _video(db, author.id, "待审核的菜", status=1)
for v in (v1, v2, v3):
db.add(VideoTag(video_id=v.id, tag_id=tag.id))
await db.commit()
r = await video_service.get_tag_videos(db, _identity(), "美食", "hot", 1, 20)
assert r["tag"] == "美食" and r["total"] == 2 # 未发布不计
assert {i["video_id"] for i in r["list"]} == {v1.id, v2.id}
@pytest.mark.asyncio
async def test_tag_videos_tag_not_exists(db):
r = await video_service.get_tag_videos(db, _identity(), "不存在", "hot", 1, 20)
assert r == {"tag": "不存在", "total": 0, "list": []}
+187
View File
@@ -0,0 +1,187 @@
"""结算服务:推荐激活(72h 窗口+次日登录)/ 创作收益增量结算 / 订单超时关单"""
import uuid
from datetime import date, datetime, timedelta
from decimal import Decimal
import pytest
from sqlalchemy import select
from app.models.billing import RechargeOrder, WatchRecord
from app.models.incentive import AssetWallet, Referral, VideoEarning
from app.models.user import User, UserMembership, UserWallet
from app.services import settle_service
# config.yaml:register_reward_points=10 / activate_watch_count=3 / activate_hours=72
REWARD = 10
WATCH_N = 3
async def _user(db, *, member=False, last_login_at=None):
u = User(password_hash="x", register_type=1, last_login_at=last_login_at,
invite_code=uuid.uuid4().hex[:8].upper())
db.add(u)
await db.flush()
if member:
db.add(UserMembership(user_id=u.id, is_member=1, is_lifetime=1))
await db.commit()
return u
async def _referral(db, inviter_id, invitee_id, *, created_at=None, status=0):
r = Referral(
inviter_id=inviter_id, invitee_id=invitee_id, invite_code="CODE123",
status=status, created_at=created_at or datetime.now(),
)
db.add(r)
await db.commit()
return r
async def _watches(db, user_id, n):
for i in range(n):
db.add(WatchRecord(
subject_type=1, subject_id=str(user_id), video_id=100 + i,
watch_date=date.today(), cost_type=0,
))
await db.commit()
# ---------- 推荐激活结算 ----------
@pytest.mark.asyncio
async def test_referral_activated_and_rewarded(db):
inviter = await _user(db, member=True)
invitee = await _user(db, last_login_at=datetime.now())
r = await _referral(db, inviter.id, invitee.id, created_at=datetime.now() - timedelta(days=1))
await _watches(db, invitee.id, WATCH_N)
result = await settle_service.settle_referral_activations(db)
assert result["activated"] == 1
await db.refresh(r)
assert r.status == 1 and r.activated_at is not None
# 推荐人是会员 → 发放注册奖励积分(biz_type=8)
assert (await db.get(UserWallet, inviter.id)).points == REWARD
@pytest.mark.asyncio
async def test_referral_expired_after_72h(db):
inviter = await _user(db, member=True)
invitee = await _user(db, last_login_at=datetime.now())
r = await _referral(db, inviter.id, invitee.id, created_at=datetime.now() - timedelta(hours=73))
await _watches(db, invitee.id, WATCH_N)
result = await settle_service.settle_referral_activations(db)
assert result["expired"] == 1 and result["activated"] == 0
await db.refresh(r)
assert r.status == 2
assert await db.get(UserWallet, inviter.id) is None # 未发奖
@pytest.mark.asyncio
async def test_referral_watches_not_enough(db):
inviter = await _user(db, member=True)
invitee = await _user(db, last_login_at=datetime.now())
r = await _referral(db, inviter.id, invitee.id, created_at=datetime.now() - timedelta(days=1))
await _watches(db, invitee.id, WATCH_N - 1)
result = await settle_service.settle_referral_activations(db)
assert result["activated"] == 0
await db.refresh(r)
assert r.status == 0 # 窗口内继续等待
@pytest.mark.asyncio
async def test_referral_requires_next_day_login(db):
inviter = await _user(db, member=True)
# 注册与登录同一天 → 不满足「次日登录」
same_day = datetime.now()
invitee = await _user(db, last_login_at=same_day)
r = await _referral(db, inviter.id, invitee.id, created_at=same_day)
await _watches(db, invitee.id, WATCH_N)
result = await settle_service.settle_referral_activations(db)
assert result["activated"] == 0
await db.refresh(r)
assert r.status == 0
@pytest.mark.asyncio
async def test_referral_non_member_inviter_no_reward(db):
inviter = await _user(db, member=False)
invitee = await _user(db, last_login_at=datetime.now())
r = await _referral(db, inviter.id, invitee.id, created_at=datetime.now() - timedelta(days=1))
await _watches(db, invitee.id, WATCH_N)
result = await settle_service.settle_referral_activations(db)
assert result["activated"] == 1 # 激活照常
await db.refresh(r)
assert r.status == 1
assert await db.get(UserWallet, inviter.id) is None # 非会员不发奖
# ---------- 视频创作收益结算 ----------
async def _earning(db, author_id, *, views=0, views_settled=0, likes=0, likes_settled=0):
e = VideoEarning(
video_id=999, author_id=author_id, complete_views=views,
views_settled=views_settled, likes_accrued=likes, likes_settled=likes_settled,
)
db.add(e)
await db.commit()
return e
@pytest.mark.asyncio
async def test_earnings_settle_full_scale(db):
author = await _user(db, member=True)
# 满 2 个观看刻度(2000/1000)+ 2 个点赞刻度(200/100),余数保留
e = await _earning(db, author.id, views=2500, likes=250)
result = await settle_service.settle_video_earnings(db)
assert result["granted_assets"] == 4.0
await db.refresh(e)
assert e.views_settled == 2000 and e.likes_settled == 200
wallet = await db.get(AssetWallet, author.id)
assert wallet.assets == Decimal(4)
# 再跑一轮:余数不足刻度,不再发放(幂等)
result2 = await settle_service.settle_video_earnings(db)
assert result2["settled_videos"] == 0
await db.refresh(wallet)
assert wallet.assets == Decimal(4)
@pytest.mark.asyncio
async def test_earnings_non_member_not_settled(db):
author = await _user(db, member=False)
e = await _earning(db, author.id, views=5000, likes=500)
result = await settle_service.settle_video_earnings(db)
assert result["settled_videos"] == 0
await db.refresh(e)
assert e.views_settled == 0 and e.likes_settled == 0 # 刻度不推进
assert await db.get(AssetWallet, author.id) is None
# ---------- 订单超时关单 ----------
@pytest.mark.asyncio
async def test_close_expired_orders(db):
def _order(no, status, expire_at):
return RechargeOrder(
order_no=no, user_id=1, amount_fen=100, points=10,
pay_method=1, status=status, expire_at=expire_at,
)
db.add(_order("R_EXP", 0, datetime.now() - timedelta(minutes=1))) # 待支付已过期 → 关单
db.add(_order("R_PEN", 0, datetime.now() + timedelta(minutes=30))) # 待支付未过期 → 保留
db.add(_order("R_PAY", 1, datetime.now() - timedelta(minutes=1))) # 已支付 → 不动
await db.commit()
result = await settle_service.close_expired_orders(db)
assert result == {"closed": 1}
status_map = {
o.order_no: o.status
for o in (await db.execute(select(RechargeOrder))).scalars()
}
assert status_map == {"R_EXP": 2, "R_PEN": 0, "R_PAY": 1}