683 lines
23 KiB
Python
683 lines
23 KiB
Python
"""斗鱼外部单协作:账号导入、状态机、鱼翅/积分与兑换记录查询。"""
|
|
|
|
import pytest
|
|
|
|
from tests.support.db import get_test_engine, make_test_session
|
|
from web.backend.models import Account, AuditLog, User
|
|
from web.backend.routers import douyu_collab as collab_api
|
|
from web.backend.schemas import (
|
|
DouyuCollabAccountLookupOut,
|
|
DouyuCollabCkExportOut,
|
|
DouyuCollabOrderOut,
|
|
DouyuCollabUsernameExportOut,
|
|
)
|
|
from web.backend.services import douyu_collab_service as collab
|
|
|
|
|
|
class FakeClient:
|
|
def __init__(self, *, points=5000, fish=1000, records=None):
|
|
self.points = points
|
|
self.fish = fish
|
|
self.records = records if records is not None else []
|
|
self.records_calls = 0
|
|
self.orders_calls = 0
|
|
|
|
def elite_user_info(self, **_):
|
|
return {
|
|
"manual_type": 1,
|
|
"manual_score": self.points,
|
|
"expire_time": 0,
|
|
"user_info": {"nickname": "斗鱼昵称"},
|
|
}
|
|
|
|
def bind_info(self, **_):
|
|
return {
|
|
"role_name": "角色A",
|
|
"area_name": "微信",
|
|
"plat_name": "苹果(iOS)",
|
|
"nickname": "微信昵称",
|
|
}
|
|
|
|
def gold_account(self):
|
|
return {"gold": self.fish}
|
|
|
|
def list_goods(self, **_):
|
|
return {
|
|
"goods": [
|
|
{
|
|
"commodityId": "gift-a",
|
|
"commodityName": "礼包A",
|
|
"score": 100,
|
|
"status": 0,
|
|
"openStatus": 1,
|
|
"mark": "",
|
|
"count": 10,
|
|
}
|
|
]
|
|
}
|
|
|
|
def exchange_records(self, **_):
|
|
self.records_calls += 1
|
|
return {"records": self.records}
|
|
|
|
def exchange_orders(self, **_):
|
|
self.orders_calls += 1
|
|
return {"orders": []}
|
|
|
|
|
|
class FakeNoBindClient(FakeClient):
|
|
def bind_info(self, **_):
|
|
raise RuntimeError("未绑定角色")
|
|
|
|
|
|
class TestDouyuCollabOrders:
|
|
def setup_method(self):
|
|
self.engine = get_test_engine()
|
|
self.session = make_test_session(self.engine)
|
|
self.admin = User(
|
|
username="douyu-collab-admin",
|
|
password_hash="hash",
|
|
role="super_admin",
|
|
is_active=True,
|
|
)
|
|
self.session.add(self.admin)
|
|
self.session.commit()
|
|
self.account = Account(
|
|
username="douyu-account-a",
|
|
password="password",
|
|
email="a@example.com",
|
|
email_password="mail-password",
|
|
cookie="acf_uid=100; acf_nickname=test",
|
|
)
|
|
self.session.add(self.account)
|
|
self.session.commit()
|
|
|
|
def teardown_method(self):
|
|
self.session.close()
|
|
|
|
def test_lookup_account_resolves_internal_ck_and_role(self):
|
|
result = collab.lookup_account(
|
|
self.session, "douyu-account-a", client=FakeClient()
|
|
)
|
|
assert result["found"] is True
|
|
assert result["ok"] is True
|
|
assert result["account_id"] == self.account.id
|
|
assert result["bind_game_role"] == "角色A"
|
|
assert result["manual_score"] == 5000
|
|
assert result["fish_fin_balance"] == 1000
|
|
assert result["goods"][0]["commodity_id"] == "gift-a"
|
|
DouyuCollabAccountLookupOut.model_validate(result)
|
|
|
|
def test_create_strict_order_uses_internal_account_and_advances(self):
|
|
order = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-001",
|
|
shop="店铺A",
|
|
account_username="douyu-account-a",
|
|
gift_name="礼包A",
|
|
required_points=100,
|
|
home_images=["collab/home-1.png"],
|
|
client=FakeClient(),
|
|
)
|
|
assert order.account_id == self.account.id
|
|
assert order.bind_game_role == "角色A"
|
|
assert order.points_balance == 5000
|
|
assert order.fish_fin_balance == 1000
|
|
assert order.status == collab.STATUS_TO_RECHARGE
|
|
DouyuCollabOrderOut.model_validate(collab.serialize_one(self.session, order))
|
|
|
|
def test_same_account_allows_multiple_orders(self):
|
|
collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-002",
|
|
shop="店铺A",
|
|
account_username="douyu-account-a",
|
|
gift_name="礼包A",
|
|
required_points=100,
|
|
home_images=["collab/home-1.png"],
|
|
client=FakeClient(),
|
|
)
|
|
second = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-003",
|
|
shop="店铺A",
|
|
account_username="douyu-account-a",
|
|
gift_name="礼包A",
|
|
required_points=100,
|
|
home_images=["collab/home-2.png"],
|
|
client=FakeClient(),
|
|
)
|
|
assert second.account_id == self.account.id
|
|
assert collab.group_points_total(self.session, second) == 200
|
|
|
|
def test_status_flow_and_exchange_record_marks_done(self):
|
|
order = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-004",
|
|
shop="店铺A",
|
|
account_username="douyu-account-a",
|
|
gift_name="礼包A",
|
|
required_points=100,
|
|
home_images=["collab/home-3.png"],
|
|
client=FakeClient(),
|
|
)
|
|
collab.set_status(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
status=collab.STATUS_RECHARGED,
|
|
reason="鱼翅已充值",
|
|
)
|
|
collab.set_status(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
status=collab.STATUS_SCRIPTED,
|
|
reason="脚本已上",
|
|
)
|
|
result = collab.query_activity(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
client=FakeClient(records=[{"commodityName": "礼包A"}]),
|
|
)
|
|
assert result["verdict"] == collab.VERDICT_SUCCESS
|
|
assert result["order"].status == collab.STATUS_DONE
|
|
assert result["order"].done_at is not None
|
|
|
|
def test_points_shortage_enters_points_error(self):
|
|
order = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-005",
|
|
shop="店铺A",
|
|
account_username="douyu-account-a",
|
|
gift_name="礼包A",
|
|
required_points=100,
|
|
home_images=["collab/home-4.png"],
|
|
client=FakeClient(),
|
|
)
|
|
collab.set_status(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
status=collab.STATUS_RECHARGED,
|
|
reason="鱼翅已充值",
|
|
)
|
|
order.points_threshold = 10
|
|
self.session.commit()
|
|
result = collab.query_activity(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
client=FakeClient(points=50, records=[]),
|
|
)
|
|
assert result["points_reason"] == "shortage"
|
|
assert result["order"].status == collab.STATUS_POINTS_ERROR
|
|
|
|
def test_balance_query_skips_records_and_never_changes_status(self):
|
|
order = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-106",
|
|
shop="店铺A",
|
|
account_username="douyu-account-a",
|
|
gift_name="礼包A",
|
|
required_points=100,
|
|
home_images=["collab/home-5.png"],
|
|
client=FakeClient(),
|
|
)
|
|
collab.set_status(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
status=collab.STATUS_RECHARGED,
|
|
reason="鱼翅已充值",
|
|
)
|
|
order.points_threshold = 10
|
|
self.session.commit()
|
|
client = FakeClient(points=50, records=[{"commodityName": "礼包A"}])
|
|
|
|
result = collab.query_activity(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
client=client,
|
|
source="balance_batch",
|
|
include_records=False,
|
|
)
|
|
|
|
assert client.records_calls == 0
|
|
assert client.orders_calls == 0
|
|
assert result["scope"] == "balances"
|
|
assert result["verdict"] == collab.VERDICT_UNKNOWN
|
|
assert result["points_reason"] == "shortage"
|
|
assert result["order"].status == collab.STATUS_RECHARGED
|
|
assert result["metric"].source == "balance_batch"
|
|
assert result["metric"].records_total == 0
|
|
|
|
def test_balance_query_keeps_last_exchange_verdict_visible(self):
|
|
order = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-107",
|
|
shop="店铺A",
|
|
account_username="douyu-account-a",
|
|
gift_name="礼包A",
|
|
required_points=100,
|
|
home_images=["collab/home-6.png"],
|
|
client=FakeClient(),
|
|
)
|
|
collab.set_status(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
status=collab.STATUS_RECHARGED,
|
|
reason="鱼翅已充值",
|
|
)
|
|
collab.set_status(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
status=collab.STATUS_SCRIPTED,
|
|
reason="脚本已上",
|
|
)
|
|
record_result = collab.query_activity(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
client=FakeClient(records=[{"commodityName": "礼包A"}]),
|
|
)
|
|
assert record_result["order"].status == collab.STATUS_DONE
|
|
|
|
collab.query_activity(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
client=FakeClient(),
|
|
source="balance_batch",
|
|
include_records=False,
|
|
)
|
|
payload = collab.serialize_one(self.session, order)
|
|
|
|
assert payload["status"] == collab.STATUS_DONE
|
|
assert payload["last_verdict"] == collab.VERDICT_SUCCESS
|
|
assert payload["last_records_matched"] == 1
|
|
assert payload["last_metric_message"] == "仅查询鱼翅与积分,未查询兑换记录"
|
|
|
|
def test_draft_can_be_created_before_account_resolution(self):
|
|
order = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-006",
|
|
shop="",
|
|
account_username="not-exists-yet",
|
|
draft=True,
|
|
)
|
|
assert order.status == collab.STATUS_INCOMPLETE
|
|
assert order.account_id is None
|
|
assert order.account_username == "not-exists-yet"
|
|
|
|
def test_draft_existing_username_links_account_and_can_be_completed(self):
|
|
order = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-009",
|
|
shop="店铺A",
|
|
account_username="douyu-account-a",
|
|
draft=True,
|
|
)
|
|
assert order.account_id == self.account.id
|
|
collab.update_order(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
{
|
|
"account_username": "douyu-account-a",
|
|
"gift_name": "礼包A",
|
|
"commodity_id": "gift-a",
|
|
"required_points": 100,
|
|
"home_images": ["collab/home-7.png"],
|
|
},
|
|
client=FakeClient(),
|
|
)
|
|
self.session.refresh(order)
|
|
assert order.bind_game_role == "角色A"
|
|
assert order.status == collab.STATUS_TO_RECHARGE
|
|
assert collab.serialize_one(self.session, order)["missing_fields"] == []
|
|
|
|
def test_missing_bind_role_keeps_incomplete_and_explains_why(self):
|
|
order = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-011",
|
|
shop="店铺A",
|
|
account_username="douyu-account-a",
|
|
draft=True,
|
|
)
|
|
collab.update_order(
|
|
self.session,
|
|
self.admin,
|
|
order,
|
|
{
|
|
"account_username": "douyu-account-a",
|
|
"gift_name": "礼包A",
|
|
"commodity_id": "gift-a",
|
|
"required_points": 100,
|
|
"home_images": ["collab/home-missing-role.png"],
|
|
},
|
|
client=FakeNoBindClient(),
|
|
)
|
|
self.session.refresh(order)
|
|
assert order.status == collab.STATUS_INCOMPLETE
|
|
payload = collab.serialize_one(self.session, order)
|
|
assert "绑定角色(先校验账号)" in payload["missing_fields"]
|
|
DouyuCollabOrderOut.model_validate(payload)
|
|
|
|
def test_done_order_is_not_counted_again_in_group_points(self):
|
|
first = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-007",
|
|
shop="店铺A",
|
|
account_username="douyu-account-a",
|
|
gift_name="礼包A",
|
|
required_points=100,
|
|
home_images=["collab/home-5.png"],
|
|
client=FakeClient(),
|
|
)
|
|
second = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-008",
|
|
shop="店铺A",
|
|
account_username="douyu-account-a",
|
|
gift_name="礼包A",
|
|
required_points=100,
|
|
home_images=["collab/home-6.png"],
|
|
client=FakeClient(),
|
|
)
|
|
assert collab.group_points_total(self.session, second) == 200
|
|
collab.set_status(
|
|
self.session,
|
|
self.admin,
|
|
first,
|
|
status=collab.STATUS_DONE,
|
|
reason="已兑换",
|
|
)
|
|
assert collab.group_points_total(self.session, second) == 100
|
|
|
|
def test_goods_normalization_keeps_zero_values(self):
|
|
goods = collab.serialize_live_goods(
|
|
[
|
|
{
|
|
"commodityId": "gift-23",
|
|
"commodityName": "23返场礼包",
|
|
"score": 8000,
|
|
"status": 0,
|
|
"openStatus": 0,
|
|
"startTime": "1792749600",
|
|
"endTime": "1793375999",
|
|
"mark": "限时",
|
|
"count": 2000,
|
|
}
|
|
]
|
|
)
|
|
assert goods[0]["status"] == "0"
|
|
assert goods[0]["open_status"] == 0
|
|
assert goods[0]["start_time"] == 1792749600
|
|
assert goods[0]["is_limited"] is True
|
|
|
|
def test_shop_rename_cascades_and_delete_rejects_in_use(self):
|
|
shop = collab.create_shop(self.session, self.admin, name="店铺B", remark="测试")
|
|
order = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-009",
|
|
shop="店铺B",
|
|
account_username="douyu-account-a",
|
|
gift_name="礼包A",
|
|
required_points=100,
|
|
home_images=["collab/home-7.png"],
|
|
client=FakeClient(),
|
|
)
|
|
collab.update_shop(
|
|
self.session,
|
|
self.admin,
|
|
shop,
|
|
{"name": "店铺B-改名", "is_active": False},
|
|
)
|
|
self.session.refresh(order)
|
|
assert order.shop == "店铺B-改名"
|
|
assert shop.is_active is False
|
|
|
|
with pytest.raises(collab.CollabError):
|
|
collab.delete_shop(self.session, self.admin, shop)
|
|
|
|
collab.delete_order(self.session, self.admin, order)
|
|
assert collab.delete_shop(self.session, self.admin, shop)["success"] is True
|
|
|
|
def test_ck_status_filter_and_summary_count_invalid(self):
|
|
order = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-010",
|
|
shop="店铺C",
|
|
account_username="douyu-account-a",
|
|
gift_name="礼包A",
|
|
required_points=100,
|
|
home_images=["collab/home-8.png"],
|
|
client=FakeClient(),
|
|
)
|
|
self.account.ck_check_status = collab.CK_INVALID
|
|
self.session.commit()
|
|
|
|
invalid = collab.list_orders(self.session, ck_status=collab.CK_INVALID)
|
|
assert [item["id"] for item in invalid["items"]] == [order.id]
|
|
assert collab.list_orders(self.session, ck_status=collab.CK_VALID)["total"] == 0
|
|
assert collab.summary(self.session)["ck_invalid"] == 1
|
|
|
|
def test_export_cks_reads_linked_account_and_skips_missing_cookie(self):
|
|
first = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-EXP-1",
|
|
account_username="douyu-account-a",
|
|
draft=True,
|
|
)
|
|
blank_account = Account(
|
|
username="douyu-account-blank",
|
|
password="password",
|
|
email="blank@example.com",
|
|
email_password="",
|
|
cookie="",
|
|
)
|
|
self.session.add(blank_account)
|
|
self.session.commit()
|
|
blank = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-EXP-2",
|
|
account_username="douyu-account-blank",
|
|
draft=True,
|
|
)
|
|
missing = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-EXP-3",
|
|
account_username="douyu-account-missing",
|
|
draft=True,
|
|
)
|
|
self.account.ck_check_status = collab.CK_VALID
|
|
self.session.commit()
|
|
|
|
result = collab.export_cks(
|
|
self.session,
|
|
self.admin,
|
|
[first.id, blank.id, missing.id],
|
|
)
|
|
|
|
assert result["total"] == 3
|
|
assert result["exported"] == 1
|
|
assert result["items"][0]["order_no"] == "DY-EXP-1"
|
|
assert result["items"][0]["ck_text"] == self.account.cookie
|
|
assert result["items"][0]["account_username"] == self.account.username
|
|
assert result["items"][0]["account_password"] == "password"
|
|
assert result["items"][0]["ck_status_label"] == "CK 正常"
|
|
assert [item["order_no"] for item in result["skipped"]] == [
|
|
"DY-EXP-2",
|
|
"DY-EXP-3",
|
|
]
|
|
assert "没有 CK" in result["skipped"][0]["reason"]
|
|
assert "还没绑定" in result["skipped"][1]["reason"]
|
|
DouyuCollabCkExportOut.model_validate(result)
|
|
|
|
def test_export_ck_audit_never_contains_the_cookie_body(self):
|
|
order = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-EXP-4",
|
|
account_username="douyu-account-a",
|
|
draft=True,
|
|
)
|
|
|
|
collab.export_cks(self.session, self.admin, [order.id])
|
|
|
|
audit = (
|
|
self.session.query(AuditLog)
|
|
.filter(AuditLog.action == collab.CK_EXPORT_ACTION)
|
|
.order_by(AuditLog.id.desc())
|
|
.first()
|
|
)
|
|
assert audit is not None
|
|
assert audit.username == self.admin.username
|
|
assert order.order_no in audit.detail
|
|
assert "douyu-account-a" in audit.detail
|
|
assert self.account.cookie not in audit.detail
|
|
|
|
def test_export_cks_rejects_empty_and_too_many(self):
|
|
with pytest.raises(collab.CollabError) as excinfo:
|
|
collab.export_cks(self.session, self.admin, [])
|
|
assert excinfo.value.code == "ck_export_empty"
|
|
|
|
with pytest.raises(collab.CollabError) as excinfo:
|
|
collab.export_cks(
|
|
self.session,
|
|
self.admin,
|
|
list(range(1, collab.CK_EXPORT_BATCH_MAX + 2)),
|
|
)
|
|
assert excinfo.value.code == "ck_export_too_large"
|
|
|
|
def test_single_row_copy_is_super_admin_only(self):
|
|
operator = User(
|
|
username="douyu-collab-operator",
|
|
password_hash="hash",
|
|
role="operation",
|
|
is_active=True,
|
|
)
|
|
self.session.add(operator)
|
|
self.session.commit()
|
|
order = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-EXP-5",
|
|
account_username="douyu-account-a",
|
|
draft=True,
|
|
)
|
|
|
|
result = collab_api.copy_external_order_ck(order.id, self.session, self.admin)
|
|
assert result["exported"] == 1
|
|
assert result["items"][0]["ck_text"] == self.account.cookie
|
|
assert result["items"][0]["account_password"] == "password"
|
|
|
|
with pytest.raises(collab_api.HTTPException) as excinfo:
|
|
collab_api.copy_external_order_ck(order.id, self.session, operator)
|
|
assert excinfo.value.status_code == 403
|
|
|
|
def test_export_usernames_dedupes_and_skips_empty(self):
|
|
first = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-USER-1",
|
|
account_username="douyu-account-a",
|
|
draft=True,
|
|
)
|
|
second = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-USER-2",
|
|
account_username="douyu-account-a",
|
|
draft=True,
|
|
)
|
|
blank = collab.create_order(
|
|
self.session,
|
|
self.admin,
|
|
order_no="DY-USER-3",
|
|
draft=True,
|
|
)
|
|
|
|
result = collab.export_usernames(
|
|
self.session,
|
|
[first.id, second.id, blank.id],
|
|
)
|
|
|
|
assert result["total"] == 3
|
|
assert result["exported"] == 2
|
|
assert result["unique"] == 1
|
|
assert result["usernames"] == ["douyu-account-a"]
|
|
assert [item["order_no"] for item in result["items"]] == [
|
|
"DY-USER-1",
|
|
"DY-USER-2",
|
|
]
|
|
assert result["skipped"] == [
|
|
{
|
|
"id": blank.id,
|
|
"order_no": "DY-USER-3",
|
|
"reason": "这单还没填斗鱼用户名",
|
|
}
|
|
]
|
|
DouyuCollabUsernameExportOut.model_validate(result)
|
|
|
|
def test_export_usernames_rejects_empty_and_too_many(self):
|
|
with pytest.raises(collab.CollabError) as excinfo:
|
|
collab.export_usernames(self.session, [])
|
|
assert excinfo.value.code == "username_export_empty"
|
|
|
|
with pytest.raises(collab.CollabError) as excinfo:
|
|
collab.export_usernames(
|
|
self.session,
|
|
list(range(1, collab.USERNAME_EXPORT_BATCH_MAX + 2)),
|
|
)
|
|
assert excinfo.value.code == "username_export_too_large"
|
|
|
|
def test_ck_export_permission_defaults_to_operation(self):
|
|
from web.backend.permissions import ROLE_PERMISSIONS
|
|
|
|
assert "douyu:collab:ck_export" in ROLE_PERMISSIONS["operation"]
|
|
assert "douyu:collab:ck_export" in ROLE_PERMISSIONS["super_admin"]
|
|
assert "douyu:collab:ck_export" not in ROLE_PERMISSIONS["support"]
|
|
|
|
def test_douyu_collab_routes_registered(self):
|
|
from web.backend.main import app
|
|
|
|
paths = app.openapi()["paths"]
|
|
assert "/api/douyu/external-orders" in paths
|
|
assert "/api/douyu/external-orders/{order_id}/status" in paths
|
|
assert "/api/douyu/external-orders/status/batch" in paths
|
|
assert "/api/douyu/external-orders/verify/batch" in paths
|
|
assert "/api/douyu/external-orders/accounts/lookup" in paths
|
|
assert "/api/douyu/external-orders/ck/export" in paths
|
|
assert "/api/douyu/external-orders/{order_id}/ck/copy" in paths
|
|
assert "/api/douyu/external-orders/usernames/export" in paths
|
|
assert "/api/douyu/external-orders/balances/query" in paths
|
|
assert "/api/douyu/external-orders/{order_id}/balances/query" in paths
|
|
assert "/api/douyu/external-orders/activity/query" in paths
|
|
assert "/api/douyu/external-orders/{order_id}/activity/query" in paths
|
|
assert "/api/douyu/collab-shops/{shop_id}" in paths
|
|
assert "put" in paths["/api/douyu/collab-shops/{shop_id}"]
|
|
assert "delete" in paths["/api/douyu/collab-shops/{shop_id}"]
|