432 lines
17 KiB
Python
432 lines
17 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""市场子应用 · 数据访问与业务服务(目录 CRUD / 状态机 / 统计 / 购买权益)。
|
||
|
||
DB 访问统一走 ``db.session``(select / get),与 rbac_hall 保持一致。
|
||
安装包存储复用 ``infrastructure.oss.oss``(OSS,未配置降级本地)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
import secrets
|
||
|
||
from sqlalchemy import select
|
||
|
||
from ..infrastructure.models import (
|
||
MarketItem,
|
||
MarketPurchase,
|
||
MarketPurchaseOrder,
|
||
)
|
||
from ..infrastructure.oss import oss, resolve_url
|
||
from ..infrastructure.repositories import utcnow_iso
|
||
|
||
# 订单号前缀(与 app.pay.service.handle_notify 分发对齐)
|
||
ORDER_PREFIX = "MK_"
|
||
|
||
|
||
def _row(r: MarketItem) -> dict:
|
||
"""目录条目对外视图(公开字段 + bundle_available / price)。"""
|
||
return {
|
||
"id": r.id,
|
||
"item_type": r.item_type,
|
||
"slug": r.slug,
|
||
"name": r.name,
|
||
"description": r.description,
|
||
"version": r.version,
|
||
"author": r.author,
|
||
"owner": r.owner,
|
||
"icon_url": resolve_url(r.icon_url),
|
||
"cover_url": resolve_url(r.cover_url),
|
||
"category": r.category,
|
||
"tags": json.loads(r.tags_json or "[]"),
|
||
"locale": json.loads(r.locale_json or "{}"),
|
||
"price_fen": r.price_fen,
|
||
"free": r.price_fen <= 0,
|
||
"is_featured": bool(r.is_featured),
|
||
"status": r.status,
|
||
"reject_reason": r.reject_reason,
|
||
"stats": json.loads(r.stats_json or "{}"),
|
||
"bundle_available": bool(r.bundle_json and r.bundle_json != "{}") or bool(r.bundle_url),
|
||
# 内部字段:安装包下发(/market/items/{id}/bundle)与 admin 详情需要;
|
||
# 公开列表仅用 bundle_available 标识,前端不必读取本字段。
|
||
"bundle_json": json.loads(r.bundle_json or "{}"),
|
||
"bundle_url": r.bundle_url,
|
||
"bundle_sha256": r.bundle_sha256,
|
||
"published_at": r.published_at,
|
||
"created_at": r.created_at,
|
||
"updated_at": r.updated_at,
|
||
}
|
||
|
||
|
||
def _order_view(o: MarketPurchaseOrder) -> dict:
|
||
return {
|
||
"order_no": o.order_no,
|
||
"item_id": o.item_id,
|
||
"item_type": o.item_type,
|
||
"item_name": o.item_name,
|
||
"amount_fen": o.amount_fen,
|
||
"status": o.status,
|
||
"code_url": o.code_url,
|
||
"prepay_id": o.prepay_id,
|
||
"expires_at": o.expires_at,
|
||
"created_at": o.created_at,
|
||
"paid_at": o.paid_at,
|
||
"credited_at": o.credited_at,
|
||
}
|
||
|
||
|
||
# ===========================================================================
|
||
# 目录查询
|
||
# ===========================================================================
|
||
async def list_items(db, *, item_type: str | None = None, q: str = "",
|
||
category: str = "", featured_only: bool = False,
|
||
region: str = "",
|
||
status: str = "published", page: int = 1, page_size: int = 50,
|
||
mine: bool = False) -> dict:
|
||
"""目录列表。status 传空串 = 全状态(管理端用)。region 按 tags_json 过滤(专区标记,如 yunnan-opc)。"""
|
||
stmt = select(MarketItem).order_by(MarketItem.is_featured.desc(), MarketItem.created_at.desc())
|
||
if item_type:
|
||
stmt = stmt.where(MarketItem.item_type == item_type)
|
||
if status:
|
||
stmt = stmt.where(MarketItem.status == status)
|
||
if category:
|
||
stmt = stmt.where(MarketItem.category == category)
|
||
if featured_only:
|
||
stmt = stmt.where(MarketItem.is_featured == 1)
|
||
if region:
|
||
stmt = stmt.where(MarketItem.tags_json.like(f"%{region}%"))
|
||
if q:
|
||
like = f"%{q}%"
|
||
stmt = stmt.where((MarketItem.name.like(like)) | (MarketItem.description.like(like)) | (MarketItem.slug.like(like)))
|
||
page = max(1, int(page))
|
||
page_size = max(1, min(200, int(page_size)))
|
||
rows = (await db.session.execute(
|
||
stmt.offset((page - 1) * page_size).limit(page_size)
|
||
)).scalars().all()
|
||
items = [_row(r) for r in rows]
|
||
return {"items": items, "page": page, "page_size": page_size, "total": len(items)}
|
||
|
||
|
||
async def get_item(db, item_id: str) -> dict | None:
|
||
r = await db.session.get(MarketItem, item_id)
|
||
return _row(r) if r else None
|
||
|
||
|
||
async def get_item_or_404(db, item_id: str) -> dict:
|
||
row = await get_item(db, item_id)
|
||
if not row:
|
||
raise LookupError("商品不存在")
|
||
return row
|
||
|
||
|
||
def bundle_payload(item: dict) -> dict:
|
||
"""skill 安装包(bundle_json 已由 _row 解析为 dict 对象)。"""
|
||
b = item.get("bundle_json") or {}
|
||
if isinstance(b, dict):
|
||
# 注入商品中文显示名,供安装端优先用作技能显示名称
|
||
if item.get("name") and not b.get("display_name"):
|
||
b["display_name"] = item["name"]
|
||
return b
|
||
return {}
|
||
|
||
|
||
# ===========================================================================
|
||
# 管理:CRUD + 状态机
|
||
# ===========================================================================
|
||
async def create_item(db, *, item_type: str, slug: str, name: str, publisher_id: str, **kw) -> dict:
|
||
now = utcnow_iso()
|
||
r = MarketItem(
|
||
id=f"mi_{secrets.token_hex(8)}",
|
||
item_type=item_type, slug=slug, name=name,
|
||
description=kw.get("description", ""), version=kw.get("version", "1.0.0"),
|
||
author=kw.get("author", ""), owner=kw.get("owner", ""),
|
||
icon_url=kw.get("icon_url", ""), cover_url=kw.get("cover_url", ""),
|
||
category=kw.get("category", ""),
|
||
tags_json=json.dumps(kw.get("tags", []), ensure_ascii=False),
|
||
locale_json=json.dumps(kw.get("locale", {}), ensure_ascii=False),
|
||
bundle_json=json.dumps(kw.get("bundle", {}), ensure_ascii=False),
|
||
bundle_url=kw.get("bundle_url", ""), bundle_sha256=kw.get("bundle_sha256", ""),
|
||
price_fen=int(kw.get("price_fen", 0) or 0),
|
||
publisher_id=publisher_id, status="draft", created_at=now, updated_at=now,
|
||
)
|
||
db.session.add(r)
|
||
await db.session.commit()
|
||
return _row(r)
|
||
|
||
|
||
async def update_item(db, item_id: str, **kw) -> dict | None:
|
||
r = await db.session.get(MarketItem, item_id)
|
||
if r is None:
|
||
return None
|
||
for key, val in kw.items():
|
||
if val is None:
|
||
continue
|
||
if key in ("tags",):
|
||
val = json.dumps(val, ensure_ascii=False)
|
||
elif key in ("bundle", "locale"):
|
||
val = json.dumps(val, ensure_ascii=False)
|
||
if hasattr(r, key):
|
||
setattr(r, key, val)
|
||
r.updated_at = utcnow_iso()
|
||
await db.session.commit()
|
||
return _row(r)
|
||
|
||
|
||
async def delete_item(db, item_id: str) -> bool:
|
||
r = await db.session.get(MarketItem, item_id)
|
||
if r is None:
|
||
return False
|
||
# 清理 OSS 安装包(best-effort)
|
||
if r.bundle_url and r.bundle_url.startswith("/oss/"):
|
||
pass # 对象清理由 OSS 生命周期策略负责,此处仅解绑
|
||
await db.session.delete(r)
|
||
await db.session.commit()
|
||
return True
|
||
|
||
|
||
async def set_status(db, item_id: str, status: str, *, reason: str = "", publisher_id: str = "") -> dict | None:
|
||
"""状态机:draft/pending/published/rejected/offline。published 需 bundle 就绪。"""
|
||
r = await db.session.get(MarketItem, item_id)
|
||
if r is None:
|
||
return None
|
||
if status == "published":
|
||
has_bundle = bool(r.bundle_json and r.bundle_json != "{}") or bool(r.bundle_url)
|
||
if not has_bundle:
|
||
raise ValueError("未上传安装包,无法上架")
|
||
r.published_at = r.published_at or utcnow_iso()
|
||
r.status = status
|
||
r.reject_reason = reason if status == "rejected" else ""
|
||
r.updated_at = utcnow_iso()
|
||
await db.session.commit()
|
||
return _row(r)
|
||
|
||
|
||
async def set_featured(db, item_id: str, featured: bool) -> dict | None:
|
||
r = await db.session.get(MarketItem, item_id)
|
||
if r is None:
|
||
return None
|
||
r.is_featured = 1 if featured else 0
|
||
r.updated_at = utcnow_iso()
|
||
await db.session.commit()
|
||
return _row(r)
|
||
|
||
|
||
async def set_bundle(db, item_id: str, *, bundle_url: str, sha256: str, bundle_json: str = "") -> dict | None:
|
||
r = await db.session.get(MarketItem, item_id)
|
||
if r is None:
|
||
return None
|
||
if bundle_url:
|
||
r.bundle_url = bundle_url
|
||
if sha256:
|
||
r.bundle_sha256 = sha256
|
||
if bundle_json:
|
||
r.bundle_json = bundle_json
|
||
r.updated_at = utcnow_iso()
|
||
await db.session.commit()
|
||
return _row(r)
|
||
|
||
|
||
# ===========================================================================
|
||
# 购买权益(免费安装 + 付费购买)
|
||
# ===========================================================================
|
||
async def has_purchase(db, user_id: str, item_id: str) -> bool:
|
||
r = await db.session.scalar(
|
||
select(MarketPurchase).where(
|
||
MarketPurchase.user_id == user_id, MarketPurchase.item_id == item_id,
|
||
).limit(1)
|
||
)
|
||
return r is not None
|
||
|
||
|
||
async def grant_purchase(db, *, user_id: str, item_id: str, item_type: str,
|
||
amount_fen: int, order_no: str = "") -> None:
|
||
await db.session.add(MarketPurchase(
|
||
id=f"mp_{secrets.token_hex(8)}", user_id=user_id, item_id=item_id,
|
||
item_type=item_type, amount_fen=amount_fen, order_no=order_no,
|
||
created_at=utcnow_iso(),
|
||
))
|
||
await db.session.commit()
|
||
|
||
|
||
async def my_purchases(db, user_id: str) -> list[str]:
|
||
rows = (await db.session.execute(
|
||
select(MarketPurchase).where(MarketPurchase.user_id == user_id)
|
||
)).scalars().all()
|
||
return [r.item_id for r in rows]
|
||
|
||
|
||
async def bump_stats(db, item_id: str, *, downloads: int = 0, views: int = 1) -> None:
|
||
r = await db.session.get(MarketItem, item_id)
|
||
if r is None:
|
||
return
|
||
stats = json.loads(r.stats_json or "{}")
|
||
stats["views"] = int(stats.get("views", 0)) + views
|
||
stats["downloads"] = int(stats.get("downloads", 0)) + downloads
|
||
r.stats_json = json.dumps(stats, ensure_ascii=False)
|
||
await db.session.commit()
|
||
|
||
|
||
# ===========================================================================
|
||
# 购买订单(复用 app.pay 微信「小程序码 → 小程序内支付」链路)
|
||
# ===========================================================================
|
||
async def create_purchase_order(db, user: dict, *, item_id: str) -> dict:
|
||
"""创建市场购买订单(client_type=mp:桌面出小程序码,扫码进小程序支付)。"""
|
||
from ..pay import config as pay_config
|
||
from ..pay import wxpay
|
||
|
||
if not pay_config.pay_enabled():
|
||
raise RuntimeError("支付未配置,暂不可用")
|
||
item = await db.session.get(MarketItem, item_id)
|
||
if item is None or item.status != "published":
|
||
raise ValueError("商品不存在或未上架")
|
||
if item.price_fen <= 0:
|
||
raise ValueError("免费商品无需购买")
|
||
# 已购直接返回(幂等)
|
||
if await has_purchase(db, user["id"], item_id):
|
||
return {"owned": True, "order": None, "qr_image": ""}
|
||
|
||
# 复用未过期 pending 单(防重复下单)
|
||
now_iso = utcnow_iso()
|
||
dup = await db.session.scalar(
|
||
select(MarketPurchaseOrder).where(
|
||
MarketPurchaseOrder.user_id == user["id"],
|
||
MarketPurchaseOrder.item_id == item_id,
|
||
MarketPurchaseOrder.status == "pending",
|
||
).order_by(MarketPurchaseOrder.created_at.desc()).limit(1)
|
||
)
|
||
if dup:
|
||
view = _order_view(dup)
|
||
if view["status"] == "pending":
|
||
return await _with_qr(db, dup, view)
|
||
|
||
order_no = f"{ORDER_PREFIX}{int(__import__('time').time())}_{secrets.token_hex(4).upper()}"
|
||
expires_at = (await _expires_iso(pay_config.RECHARGE_EXPIRE_MINUTES * 60))
|
||
r = MarketPurchaseOrder(
|
||
id=f"mo_{secrets.token_hex(8)}", order_no=order_no,
|
||
item_id=item.id, item_type=item.item_type, item_name=item.name,
|
||
user_id=user["id"], username=user.get("username", ""),
|
||
amount_fen=int(item.price_fen), client_type="mp", status="pending",
|
||
expires_at=expires_at, created_at=now_iso, updated_at=now_iso,
|
||
)
|
||
db.session.add(r)
|
||
await db.session.commit()
|
||
view = _order_view(r)
|
||
return await _with_qr(db, r, view)
|
||
|
||
|
||
async def _with_qr(db, order: MarketPurchaseOrder, view: dict) -> dict:
|
||
"""mp 模式:生成小程序码(复用 wechat.get_wxacode,page=pages-extra/pay/index)。"""
|
||
from ..services import wechat
|
||
import base64 as _b64
|
||
try:
|
||
png = await wechat.get_wxacode(order.order_no, page="pages-extra/pay/index")
|
||
view["qr_image"] = f"data:image/png;base64,{_b64.b64encode(png).decode('ascii')}"
|
||
except wechat.WechatError:
|
||
view["qr_image"] = ""
|
||
return {"owned": False, "order": view, "qr_image": view.get("qr_image", "")}
|
||
|
||
|
||
async def _expires_iso(seconds: int) -> str:
|
||
from datetime import datetime, timedelta, timezone
|
||
return (datetime.now(timezone.utc) + timedelta(seconds=seconds)).isoformat()
|
||
|
||
|
||
async def build_pay_params(db, user: dict, order_no: str) -> dict:
|
||
"""小程序扫码进入确认支付页:按当前小程序用户 openid 发起 JSAPI 下单,
|
||
返回 wx.requestPayment 参数。支持代付:任意登录用户均可支付,商品发放到订单创建者。"""
|
||
from ..pay import wxpay
|
||
from ..infrastructure.repositories import utcnow_iso as _now
|
||
|
||
r = await db.session.scalar(select(MarketPurchaseOrder).where(MarketPurchaseOrder.order_no == order_no))
|
||
if r is None:
|
||
raise ValueError("订单不存在")
|
||
if r.status != "pending":
|
||
return {"order": _order_view(r), "pay_params": None, "finished": True}
|
||
if r.expires_at and r.expires_at < _now():
|
||
r.status = "closed"
|
||
await db.session.commit()
|
||
return {"order": _order_view(r), "pay_params": None, "finished": True}
|
||
openid = user.get("wx_mini_openid", "")
|
||
if not openid:
|
||
raise ValueError("当前账号未绑定小程序微信身份,请先用微信登录小程序")
|
||
result = await wxpay.create_order(
|
||
client_type="jsapi", out_trade_no=order_no, total_fen=int(r.amount_fen),
|
||
description=f"购买 {r.item_name}", openid=openid,
|
||
)
|
||
r.appid = result.get("appid", "")
|
||
r.prepay_id = result.get("prepay_id", "")
|
||
await db.session.commit()
|
||
return {"order": _order_view(r), "pay_params": result.get("pay_params"), "finished": False}
|
||
|
||
|
||
async def query_status(db, user: dict, order_no: str) -> dict:
|
||
"""查询订单状态(含主动对账补单)。"""
|
||
from ..pay import wxpay
|
||
from ..infrastructure.repositories import utcnow_iso as _now
|
||
|
||
r = await db.session.scalar(select(MarketPurchaseOrder).where(MarketPurchaseOrder.order_no == order_no))
|
||
if r is None:
|
||
raise ValueError("订单不存在")
|
||
if r.status == "pending":
|
||
if r.expires_at and r.expires_at < _now():
|
||
r.status = "closed"
|
||
await db.session.commit()
|
||
else:
|
||
data = await wxpay.query_order(order_no)
|
||
state = (data or {}).get("trade_state", "")
|
||
if state == "SUCCESS":
|
||
await process_paid(db, r, data)
|
||
elif state in ("CLOSED", "PAY_ERROR", "REVOKED"):
|
||
r.status = "closed"
|
||
await db.session.commit()
|
||
return {
|
||
"order_no": r.order_no, "status": r.status,
|
||
"paid": r.status in ("paid", "credited"),
|
||
"credited": r.status == "credited",
|
||
"amount_fen": r.amount_fen, "item_id": r.item_id, "item_name": r.item_name,
|
||
"transaction_id": r.transaction_id, "expires_at": r.expires_at,
|
||
"paid_at": r.paid_at or "", "credited_at": r.credited_at or "",
|
||
}
|
||
|
||
|
||
async def process_paid(db, r: MarketPurchaseOrder, wechat_data: dict) -> bool:
|
||
"""支付成功 → 发放权益(幂等:仅 pending → paid → 发权益 → credited)。"""
|
||
if r.status in ("paid", "credited"):
|
||
return True
|
||
import json as _json
|
||
r.status = "paid"
|
||
r.transaction_id = wechat_data.get("transaction_id", "")
|
||
r.notify_payload = _json.dumps(wechat_data, ensure_ascii=False)[:8000]
|
||
r.paid_at = utcnow_iso()
|
||
await db.session.commit()
|
||
try:
|
||
await grant_purchase(
|
||
db, user_id=r.user_id, item_id=r.item_id, item_type=r.item_type,
|
||
amount_fen=int(r.amount_fen), order_no=r.order_no,
|
||
)
|
||
r.status = "credited"
|
||
r.credited_at = utcnow_iso()
|
||
await db.session.commit()
|
||
await db.audit.add(action="market.purchase_credited", resource="market_purchase_order",
|
||
resource_id=r.order_no, detail=f"{r.item_name} x {r.amount_fen}分",
|
||
user_id=r.user_id)
|
||
except Exception as exc: # noqa: BLE001
|
||
import logging
|
||
logging.getLogger("market.service").error("市场购买发放失败 order=%s: %s", r.order_no, exc, exc_info=True)
|
||
r.status = "failed"
|
||
await db.session.commit()
|
||
return False
|
||
return True
|
||
|
||
|
||
async def handle_notify(db, out_trade_no: str, result: dict) -> bool:
|
||
"""微信回调:MK_ 前缀 → 市场购买到账(由 app.pay.handle_notify 分发调用)。"""
|
||
r = await db.session.scalar(select(MarketPurchaseOrder).where(MarketPurchaseOrder.order_no == out_trade_no))
|
||
if r is None:
|
||
return True
|
||
return await process_paid(db, r, result)
|
||
|
||
|
||
def compute_sha256(data: bytes) -> str:
|
||
return hashlib.sha256(data).hexdigest()
|