add
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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": []}
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user