会员积分改版V1
This commit is contained in:
@@ -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()))
|
||||
Reference in New Issue
Block a user