会员积分改版V1

This commit is contained in:
2026-08-11 09:24:18 +08:00
parent fe24e51b97
commit b9fd07f293
111 changed files with 9355 additions and 3900 deletions
@@ -0,0 +1,66 @@
from __future__ import annotations
import asyncio
import json
from sqlalchemy import text
from app.models.base import async_session
from app.services.credit.utils import utc_now
_UNRESOLVED_HOLDS_SQL = text(
"""
SELECT
h.id,
h.user_id,
h.biz_key,
h.amount,
h.created_at
FROM credit_records AS h
WHERE h.type = 'hold'
AND NOT EXISTS (
SELECT 1
FROM credit_records AS x
WHERE x.refund_for_biz_key = h.biz_key
OR x.biz_key IN (
REPLACE(h.biz_key, :hold_suffix, :hold_release_suffix),
REPLACE(h.biz_key, :hold_suffix, :charge_suffix)
)
)
ORDER BY h.created_at ASC
LIMIT 5000
"""
)
async def amain() -> int:
"""维护窗口检查脚本:列出旧 HOLD 未形成 RELEASE/CHARGE 的业务,禁止带病切换。"""
async with async_session() as db:
result = await db.execute(
_UNRESOLVED_HOLDS_SQL,
{
"hold_suffix": ":hold",
"hold_release_suffix": ":hold_release",
"charge_suffix": ":charge",
},
)
rows = result.mappings().all()
print(
json.dumps(
{
"checked_at": utc_now().isoformat(),
"unresolved_count": len(rows),
"items": [dict(row) for row in rows],
},
ensure_ascii=False,
indent=2,
default=str,
)
)
return 0 if not rows else 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(amain()))
@@ -0,0 +1,96 @@
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from decimal import Decimal
from sqlalchemy import text
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.enums.credit_record import CreditRecordType
from app.models.base import async_session
from app.services.credit.ledger_service import grant_credits
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import to_credit_decimal, utc_now
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="将 users.credits 一次性迁移到动态积分余额表。")
parser.add_argument("--batch-size", type=int, default=500)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--commit", action="store_true")
parser.add_argument("--after-id", default="")
return parser
async def amain(argv: list[str]) -> int:
args = build_parser().parse_args(argv)
if args.dry_run and args.commit:
raise SystemExit("--dry-run 和 --commit 不能同时使用")
do_commit = bool(args.commit)
batch_size = max(1, min(int(args.batch_size or 500), 5000))
migration_time = utc_now()
cursor = str(args.after_id or "")
stats = {"positive_users": 0, "zero_users": 0, "negative_reset_users": 0, "migrated_credits": "0.00", "last_id": cursor}
total = Decimal("0.00")
while True:
async with async_session() as db:
rows = (await db.execute(
text("SELECT id, credits FROM users WHERE id > :cursor ORDER BY id ASC LIMIT :limit"),
{"cursor": cursor, "limit": batch_size},
)).mappings().all()
if not rows:
break
try:
for row in rows:
user_id = str(row["id"])
legacy = to_credit_decimal(row.get("credits") or 0)
cursor = user_id
stats["last_id"] = cursor
if legacy > 0:
stats["positive_users"] += 1
total += legacy
if do_commit:
await grant_credits(
db,
user_id=user_id,
amount=legacy,
description="历史用户积分迁移",
source_type=CreditBalanceSourceType.LEGACY_MIGRATION.value,
valid_from=migration_time,
expires_at=add_natural_months(migration_time, 1),
credit_level=CreditLevel.GENERAL.value,
source_id=user_id,
related_id=user_id,
record_type=CreditRecordType.RECHARGE.value,
biz_key=f"legacy-user-credits:{user_id}",
metadata_json={"legacy_credits": str(legacy), "migration_time": migration_time.isoformat()},
request_time=migration_time,
)
elif legacy < 0:
stats["negative_reset_users"] += 1
else:
stats["zero_users"] += 1
if do_commit:
await db.commit()
else:
await db.rollback()
except Exception:
await db.rollback()
raise
stats["migrated_credits"] = str(total.quantize(Decimal("0.01")))
stats["migration_time"] = migration_time.isoformat()
stats["mode"] = "commit" if do_commit else "dry-run"
print(json.dumps(stats, ensure_ascii=False, indent=2))
return 0
def main() -> int:
return asyncio.run(amain(sys.argv[1:]))
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,38 @@
from __future__ import annotations
import asyncio
import json
from sqlalchemy import text
from app.models.base import async_session
from app.services.credit.utils import utc_now
async def amain() -> int:
async with async_session() as db:
rows = (await db.execute(text("""
SELECT e.id, e.total_call_count, e.successful_call_count, e.failed_call_count,
e.total_input_tokens, e.total_output_tokens, e.total_tokens,
COUNT(a.id) AS actual_calls,
COALESCE(SUM(CASE WHEN a.status = 'succeeded' THEN 1 ELSE 0 END), 0) AS actual_success,
COALESCE(SUM(CASE WHEN a.status IN ('failed','timeout','unknown') THEN 1 ELSE 0 END), 0) AS actual_failed,
COALESCE(SUM(a.input_tokens), 0) AS actual_input,
COALESCE(SUM(a.output_tokens), 0) AS actual_output,
COALESCE(SUM(a.total_tokens), 0) AS actual_total
FROM llm_billing_executions e
LEFT JOIN llm_call_attempts a ON a.billing_execution_id = e.id
GROUP BY e.id
HAVING e.total_call_count <> COUNT(a.id)
OR e.successful_call_count <> COALESCE(SUM(CASE WHEN a.status = 'succeeded' THEN 1 ELSE 0 END), 0)
OR e.failed_call_count <> COALESCE(SUM(CASE WHEN a.status IN ('failed','timeout','unknown') THEN 1 ELSE 0 END), 0)
OR e.total_tokens <> COALESCE(SUM(a.total_tokens), 0)
ORDER BY e.id
LIMIT 1000
"""))).mappings().all()
print(json.dumps({"checked_at": utc_now().isoformat(), "mismatch_count": len(rows), "items": [dict(r) for r in rows]}, ensure_ascii=False, indent=2, default=str))
return 0 if not rows else 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(amain()))
@@ -0,0 +1,39 @@
from __future__ import annotations
import asyncio
import json
from decimal import Decimal
from sqlalchemy import text
from app.models.base import async_session
from app.services.credit.utils import utc_now
async def amain() -> int:
checked_at = utc_now()
async with async_session() as db:
row = (await db.execute(text("""
SELECT
COALESCE(SUM(CASE WHEN credits > 0 THEN credits ELSE 0 END), 0) AS legacy_positive,
COALESCE((SELECT SUM(grant_amount) FROM user_credit_balances WHERE source_type = 'legacy_migration'), 0) AS migrated_grant,
COALESCE((SELECT SUM(unspent_amount) FROM user_credit_balances WHERE source_type = 'legacy_migration'), 0) AS migrated_unspent,
COALESCE((SELECT COUNT(*) FROM users WHERE credits < 0), 0) AS legacy_negative_users
FROM users
"""))).mappings().one()
legacy = Decimal(str(row["legacy_positive"] or 0)).quantize(Decimal("0.01"))
migrated = Decimal(str(row["migrated_grant"] or 0)).quantize(Decimal("0.01"))
output = {
"checked_at": checked_at.isoformat(),
"legacy_positive": str(legacy),
"migrated_grant": str(migrated),
"migrated_unspent": str(Decimal(str(row["migrated_unspent"] or 0)).quantize(Decimal("0.01"))),
"legacy_negative_users_reset_to_zero": int(row["legacy_negative_users"] or 0),
"matched": legacy == migrated,
}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0 if output["matched"] else 2
if __name__ == "__main__":
raise SystemExit(asyncio.run(amain()))