Files
server-core/app/market/service.py
T

432 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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_wxacodepage=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()