fix: 会员充值链路加固(体验资格、积分分红、czjilu审计、余额抵扣)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-08 00:07:54 +08:00
parent 1e3d65e0e4
commit 7dc78acb73
6 changed files with 428 additions and 131 deletions

View File

@@ -2,11 +2,12 @@
会员微信充值履约(回调 / 前端确认共用,幂等)。
分红策略(定稿 v2
- formal_cishu本俱乐部 + 打手 + 会员,czjilu 已支付正式单(is_trial=false)笔数 + 1
与换管事无关;体验单不计入。
- formal_cishu本俱乐部 + 打手 + 会员,已支付正式单笔数 + 1体验单及历史脏数据不计入
- formal_cishu=1duoci_fenhong 或 club 默认正式分成。
- formal_cishu>=2仅 duoci_fenhong无配置则不分。
- 体验单:固定 trial_guanshifc / trial_zuzhangfc不计入 formal_cishu
- 体验单:固定 trial_guanshifc / trial_zuzhangfc终身仅可购 1 次
- 积分:体验 +5正式 +5体验后或直接至 10未买过体验封顶 10。
- 履约(回调/轮询/余额)均二次校验体验资格;分红失败回滚事务。
财务:每笔成功履约必须 czjilu.zhuangtai=3 + 收支入账record_wechat_income_once
"""
@@ -45,6 +46,11 @@ MEMBER_FENHONG_LEIXING = 1
MEMBER_LEIXING = 1
MEMBER_PAID_STATUS = 3
MEMBER_PENDING_STATUS = 9
MEMBER_FAILED_STATUS = 8
MEMBER_CANCELLED_STATUS = 2
MEMBER_JIFEN_CAP = 10
MEMBER_JIFEN_TRIAL_GRANT = 5
MEMBER_JIFEN_FORMAL_GRANT = 5
class MemberRechargeError(Exception):
@@ -84,17 +90,70 @@ def club_huiyuan_sellable(club_id, huiyuan_id, is_trial=False):
return True, price, days
def has_formal_member_purchase(yonghuid, huiyuan_id, club_id):
"""是否已有成功支付的正式会员单(用于禁止再买体验)。"""
def _order_is_trial_like(order, club_id=None):
"""识别体验单(含历史 is_trial 未回填的脏数据)。"""
if bool(getattr(order, 'is_trial', False)):
return True
club_id = club_id or getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
huiyuan_id = getattr(order, 'huiyuan_id', None)
if not huiyuan_id:
return False
row = get_club_huiyuan_row(club_id, huiyuan_id, require_enabled=False)
if not row or not row.trial_enabled:
return False
trial_price = Decimal(str(row.trial_price or 0))
trial_days = int(row.trial_days or 0)
if trial_price <= 0 or trial_days <= 0:
return False
order_jine = Decimal(str(order.jine or 0))
if abs(order_jine - trial_price) > Decimal('0.01'):
return False
order_days = int(getattr(order, 'purchase_days', None) or 0)
return order_days in (0, trial_days)
def _iter_paid_member_orders(yonghuid, huiyuan_id, club_id, exclude_dingdan_id=None):
club_id = club_id or CLUB_ID_DEFAULT
return Czjilu.query.filter(
qs = Czjilu.query.filter(
yonghuid=yonghuid,
huiyuan_id=huiyuan_id,
leixing=MEMBER_LEIXING,
zhuangtai=MEMBER_PAID_STATUS,
club_id=club_id,
is_trial=False,
).exists()
)
if exclude_dingdan_id:
qs = qs.exclude(dingdan_id=exclude_dingdan_id)
return qs
def has_paid_trial_purchase(yonghuid, huiyuan_id, club_id, exclude_dingdan_id=None):
"""是否已有成功支付体验单(含历史脏数据)。"""
club_id = club_id or CLUB_ID_DEFAULT
for order in _iter_paid_member_orders(yonghuid, huiyuan_id, club_id, exclude_dingdan_id):
if _order_is_trial_like(order, club_id):
return True
return False
def has_formal_member_purchase(yonghuid, huiyuan_id, club_id, exclude_dingdan_id=None):
"""是否已有成功支付的正式会员单(用于禁止再买体验)。"""
club_id = club_id or CLUB_ID_DEFAULT
for order in _iter_paid_member_orders(yonghuid, huiyuan_id, club_id, exclude_dingdan_id):
if not _order_is_trial_like(order, club_id):
return True
return False
def _sync_trial_usage_flags(yonghuid, huiyuan_id, club_id):
"""根据 czjilu 历史回填 huiyuangoumai.has_used_trial。"""
goumai = Huiyuangoumai.query.filter(
yonghu_id=yonghuid, huiyuan_id=huiyuan_id,
).first()
if not goumai:
return
if has_paid_trial_purchase(yonghuid, huiyuan_id, club_id) and not goumai.has_used_trial:
goumai.has_used_trial = True
goumai.save(update_fields=['has_used_trial', 'UpdateTime'])
def can_purchase_trial(yonghuid, huiyuan_id, club_id):
@@ -104,6 +163,11 @@ def can_purchase_trial(yonghuid, huiyuan_id, club_id):
if not sellable:
return False, '本俱乐部未开启体验会员'
_sync_trial_usage_flags(yonghuid, huiyuan_id, club_id)
if has_paid_trial_purchase(yonghuid, huiyuan_id, club_id):
return False, '体验会员仅可购买一次'
goumai = Huiyuangoumai.query.filter(
yonghu_id=yonghuid, huiyuan_id=huiyuan_id,
).first()
@@ -137,20 +201,61 @@ def validate_member_purchase(yonghuid, huiyuan_id, club_id, is_trial):
def resolve_formal_purchase_cishu(yonghuid, huiyuan_id, current_dingdan_id, club_id):
"""
打手第几次正式购买该会员duoci_fenhong.cishu
仅统计 is_trial=false 的已支付 czjilu
体验单(含历史脏数据)不计入
"""
club_id = club_id or CLUB_ID_DEFAULT
prior_paid = Czjilu.query.filter(
yonghuid=yonghuid,
huiyuan_id=huiyuan_id,
leixing=MEMBER_LEIXING,
zhuangtai=MEMBER_PAID_STATUS,
club_id=club_id,
is_trial=False,
).exclude(dingdan_id=current_dingdan_id).count()
prior_paid = 0
for order in _iter_paid_member_orders(yonghuid, huiyuan_id, club_id, current_dingdan_id):
if not _order_is_trial_like(order, club_id):
prior_paid += 1
return prior_paid + 1
def _truncate_shuoming(text, max_len=100):
text = (text or '').strip()
return text[:max_len] if text else ''
def _mark_member_order_failed(order, reason, *, record_income=False, club_id=None):
"""已收款或已扣款但履约失败:写失败状态,不发会员/积分/分红。"""
club_id = club_id or getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
base = (order.shuoming or '').strip()
order.zhuangtai = MEMBER_FAILED_STATUS
order.shuoming = _truncate_shuoming(f'{base} | 履约失败:{reason}')
order.save(update_fields=['zhuangtai', 'shuoming', 'UpdateTime'])
if record_income:
try:
_record_member_income(order, club_id)
except MemberRechargeError:
logger.error('失败单记收入异常 dingdan=%s', order.dingdan_id, exc_info=True)
logger.error('会员订单履约失败 dingdan=%s reason=%s', order.dingdan_id, reason)
def _require_paid_order_for_benefits(order):
"""仅已支付(3)订单可发会员权益/积分/分红。"""
if int(getattr(order, 'zhuangtai', 0) or 0) != MEMBER_PAID_STATUS:
raise MemberRechargeError(
f'订单未支付成功(zhuangtai={order.zhuangtai}),禁止发放权益',
'not_paid',
)
def _validate_member_fulfillment(order, club_id):
"""履约前二次校验(防止待支付旧单绕过下单校验)。"""
club_id = club_id or CLUB_ID_DEFAULT
is_trial = bool(getattr(order, 'is_trial', False))
if is_trial or _order_is_trial_like(order, club_id):
ok, msg = can_purchase_trial(order.yonghuid, order.huiyuan_id, club_id)
if not ok:
raise MemberRechargeError(msg or '体验会员不可购买', 'trial_not_allowed')
return
ok, msg, _, _ = validate_member_purchase(
order.yonghuid, order.huiyuan_id, club_id, is_trial=False,
)
if not ok:
raise MemberRechargeError(msg or '会员不可购买', 'purchase_not_allowed')
# 兼容旧名
def resolve_member_purchase_cishu(yonghuid, huiyuan_id, current_dingdan_id, club_id):
return resolve_formal_purchase_cishu(yonghuid, huiyuan_id, current_dingdan_id, club_id)
@@ -160,50 +265,54 @@ def fulfill_member_balance_purchase(czjilu, huiyuan, club_id=None):
"""余额抵扣购买会员:按俱乐部配置天数履约,并计入购买次数/分红。"""
club_id = club_id or getattr(czjilu, 'club_id', None) or CLUB_ID_DEFAULT
is_trial = bool(getattr(czjilu, 'is_trial', False))
purchase_days = int(getattr(czjilu, 'purchase_days', None) or 0)
if purchase_days <= 0:
_, _, purchase_days = club_huiyuan_sellable(club_id, czjilu.huiyuan_id, is_trial=is_trial)
formal_cishu = None
trial_guanshi_fc = Decimal('0')
trial_zuzhangfc = Decimal('0')
if is_trial:
row = get_club_huiyuan_row(club_id, czjilu.huiyuan_id)
if row:
trial_guanshi_fc = Decimal(str(row.trial_guanshifc or 0))
trial_zuzhangfc = Decimal(str(row.trial_zuzhangfc or 0))
else:
formal_cishu = resolve_formal_purchase_cishu(
czjilu.yonghuid, czjilu.huiyuan_id, czjilu.dingdan_id, club_id,
raise MemberRechargeError(
'体验会员仅支持微信支付,不可用佣金、分红或押金抵扣',
'trial_balance_forbidden',
)
goumai_record, is_first_buy_any = _apply_huiyuan_entitlement(
czjilu.yonghuid, czjilu.huiyuan_id, huiyuan, club_id, is_trial, purchase_days,
ok, err_msg, _, _ = validate_member_purchase(
czjilu.yonghuid, czjilu.huiyuan_id, club_id, is_trial=False,
)
if is_first_buy_any:
_add_first_buy_jifen(czjilu.yonghuid)
if not ok:
raise MemberRechargeError(err_msg or '会员不可购买', 'purchase_not_allowed')
purchase_days = int(getattr(czjilu, 'purchase_days', None) or 0)
if purchase_days <= 0:
_, _, purchase_days = club_huiyuan_sellable(club_id, czjilu.huiyuan_id, is_trial=False)
formal_cishu = resolve_formal_purchase_cishu(
czjilu.yonghuid, czjilu.huiyuan_id, czjilu.dingdan_id, club_id,
)
trial_guanshi_fc = Decimal('0')
trial_zuzhangfc = Decimal('0')
czjilu.zhuangtai = MEMBER_PAID_STATUS
if not czjilu.purchase_days:
czjilu.purchase_days = purchase_days
czjilu.save(update_fields=['zhuangtai', 'purchase_days'])
czjilu.is_trial = False
czjilu.save(update_fields=['zhuangtai', 'purchase_days', 'is_trial'])
_require_paid_order_for_benefits(czjilu)
goumai_record, _ = _apply_huiyuan_entitlement(
czjilu.yonghuid, czjilu.huiyuan_id, huiyuan, club_id, False, purchase_days,
)
new_jifen = _apply_member_purchase_jifen(
czjilu.yonghuid, is_trial=False, huiyuan_id=czjilu.huiyuan_id,
club_id=club_id, current_dingdan_id=czjilu.dingdan_id,
)
huiyuan.goumai_cishu = (huiyuan.goumai_cishu or 0) + 1
huiyuan.save(update_fields=['goumai_cishu'])
try:
_apply_member_fenhong(
czjilu, huiyuan,
formal_cishu=formal_cishu,
trial_guanshi_fc=trial_guanshi_fc,
trial_zuzhangfc=trial_zuzhangfc,
)
except Exception as exc:
logger.error(
'余额购会员分红失败 dingdan=%s: %s', czjilu.dingdan_id, exc, exc_info=True,
)
_apply_member_fenhong(
czjilu, huiyuan,
formal_cishu=formal_cishu,
trial_guanshi_fc=trial_guanshi_fc,
trial_zuzhangfc=trial_zuzhangfc,
)
return goumai_record, is_first_buy_any
return goumai_record, new_jifen
def _lookup_duoci_fenhong(club_id, huiyuan_id, beneficiary_id, cishu, field):
@@ -313,6 +422,8 @@ def _apply_huiyuan_entitlement(yonghuid, huiyuan_id, huiyuan, club_id, is_trial,
existing.has_used_trial = True
else:
existing.is_trial = False
if has_paid_trial_purchase(yonghuid, huiyuan_id, club_id):
existing.has_used_trial = True
existing.save(update_fields=[
'daoqi_time', 'huiyuan_zhuangtai', 'jieshao', 'is_trial', 'has_used_trial', 'UpdateTime',
])
@@ -321,6 +432,7 @@ def _apply_huiyuan_entitlement(yonghuid, huiyuan_id, huiyuan, club_id, is_trial,
other = Huiyuangoumai.query.filter(yonghu_id=yonghuid).exclude(huiyuan_id=huiyuan_id).count()
if other == 0:
is_first_buy_any = True
used_trial_before = has_paid_trial_purchase(yonghuid, huiyuan_id, club_id)
resolved_club = club_id or get_user_club_id(User.query.filter(UserUID=yonghuid).first())
record = Huiyuangoumai.query.create(
yonghu_id=yonghuid,
@@ -330,12 +442,18 @@ def _apply_huiyuan_entitlement(yonghuid, huiyuan_id, huiyuan, club_id, is_trial,
daoqi_time=new_daoqi,
club_id=resolved_club,
is_trial=is_trial,
has_used_trial=is_trial,
has_used_trial=is_trial or used_trial_before,
)
return record, is_first_buy_any
def _apply_member_fenhong(order, huiyuan, formal_cishu=None, trial_guanshi_fc=None, trial_zuzhang_fc=None):
if int(getattr(order, 'zhuangtai', 0) or 0) != MEMBER_PAID_STATUS:
logger.warning(
'订单未支付成功,禁止分红 dingdan=%s zhuangtai=%s',
order.dingdan_id, order.zhuangtai,
)
return
if Gsfenhong.query.filter(dingdan_id=order.dingdan_id).exists():
logger.info('会员分红已处理,跳过: %s', order.dingdan_id)
return
@@ -368,7 +486,7 @@ def _apply_member_fenhong(order, huiyuan, formal_cishu=None, trial_guanshi_fc=No
club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
huiyuan_id = huiyuan.huiyuan_id
order_amount = Decimal(str(order.jine))
is_trial_order = bool(getattr(order, 'is_trial', False))
is_trial_order = _order_is_trial_like(order, club_id)
if is_trial_order:
guanshi_fenhong = Decimal(str(trial_guanshi_fc or 0))
@@ -460,13 +578,55 @@ def _apply_member_fenhong(order, huiyuan, formal_cishu=None, trial_guanshi_fc=No
)
def _add_first_buy_jifen(yonghuid):
def _apply_member_purchase_jifen(yonghuid, is_trial, huiyuan_id, club_id, current_dingdan_id=None):
"""
会员购积分:体验 +5正式 +5体验后或直接到 10未买过体验封顶 10。
"""
try:
dashou = UserDashou.query.get(user__UserUID=yonghuid)
dashou.jifen += 10
dashou.save(update_fields=['jifen'])
except UserDashou.DoesNotExist:
logger.warning('首次购会员积分失败,打手不存在: %s', yonghuid)
logger.warning('会员积分失败,打手不存在: %s', yonghuid)
return 0
current = int(dashou.jifen or 0)
if current >= MEMBER_JIFEN_CAP:
return current
if is_trial:
delta = min(MEMBER_JIFEN_TRIAL_GRANT, MEMBER_JIFEN_CAP - current)
else:
had_trial = has_paid_trial_purchase(
yonghuid, huiyuan_id, club_id, exclude_dingdan_id=current_dingdan_id,
)
if had_trial:
delta = min(MEMBER_JIFEN_FORMAL_GRANT, MEMBER_JIFEN_CAP - current)
else:
delta = MEMBER_JIFEN_CAP - current
if delta <= 0:
return current
dashou.jifen = min(current + delta, MEMBER_JIFEN_CAP)
dashou.save(update_fields=['jifen'])
logger.info(
'会员购积分 yonghu=%s is_trial=%s huiyuan=%s +%s -> %s',
yonghuid, is_trial, huiyuan_id, delta, dashou.jifen,
)
return dashou.jifen
def _add_first_buy_jifen(yonghuid):
"""兼容旧调用:等价于直接买正式且从未购会员积分。"""
logger.warning('_add_first_buy_jifen 已废弃,请改用 _apply_member_purchase_jifen')
try:
dashou = UserDashou.query.get(user__UserUID=yonghuid)
except UserDashou.DoesNotExist:
return
current = int(dashou.jifen or 0)
if current >= MEMBER_JIFEN_CAP:
return
dashou.jifen = MEMBER_JIFEN_CAP
dashou.save(update_fields=['jifen'])
def repair_member_entitlement_if_paid(dingdan_id):
@@ -488,11 +648,34 @@ def repair_member_entitlement_if_paid(dingdan_id):
club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
days = order.purchase_days or 30
is_trial = _order_is_trial_like(order, club_id)
with transaction.atomic():
_apply_huiyuan_entitlement(
order.yonghuid, order.huiyuan_id, huiyuan, club_id,
bool(getattr(order, 'is_trial', False)), days,
order.yonghuid, order.huiyuan_id, huiyuan, club_id, is_trial, days,
)
_apply_member_purchase_jifen(
order.yonghuid, is_trial=is_trial, huiyuan_id=order.huiyuan_id,
club_id=club_id, current_dingdan_id=order.dingdan_id,
)
if not Gsfenhong.query.filter(dingdan_id=dingdan_id, fenhong_leixing=MEMBER_FENHONG_LEIXING).exists():
trial_guanshi_fc = Decimal('0')
trial_zuzhang_fc = Decimal('0')
formal_cishu = None
if is_trial:
row = get_club_huiyuan_row(club_id, order.huiyuan_id)
if row:
trial_guanshi_fc = Decimal(str(row.trial_guanshifc or 0))
trial_zuzhang_fc = Decimal(str(row.trial_zuzhangfc or 0))
else:
formal_cishu = resolve_formal_purchase_cishu(
order.yonghuid, order.huiyuan_id, order.dingdan_id, club_id,
)
_apply_member_fenhong(
order, huiyuan,
formal_cishu=formal_cishu,
trial_guanshi_fc=trial_guanshi_fc,
trial_zuzhang_fc=trial_zuzhang_fc,
)
logger.warning('已补开通会员 dingdan=%s', dingdan_id)
return True
@@ -536,7 +719,21 @@ def fulfill_member_recharge(dingdan_id, paid_amount_yuan=None, source='callback'
raise MemberRechargeError(f'会员不存在: {order.huiyuan_id}', 'huiyuan_not_found')
club_id = getattr(order, 'club_id', None) or CLUB_ID_DEFAULT
is_trial = bool(getattr(order, 'is_trial', False))
try:
_validate_member_fulfillment(order, club_id)
except MemberRechargeError as exc:
_mark_member_order_failed(
order, str(exc),
record_income=paid_amount_yuan is not None,
club_id=club_id,
)
raise
is_trial = _order_is_trial_like(order, club_id)
if is_trial and not order.is_trial:
order.is_trial = True
order.save(update_fields=['is_trial'])
purchase_days = order.purchase_days or (30 if not is_trial else 0)
if purchase_days <= 0:
_, _, purchase_days = club_huiyuan_sellable(club_id, order.huiyuan_id, is_trial=is_trial)
@@ -554,31 +751,33 @@ def fulfill_member_recharge(dingdan_id, paid_amount_yuan=None, source='callback'
order.yonghuid, order.huiyuan_id, order.dingdan_id, club_id,
)
goumai_record, is_first_buy_any = _apply_huiyuan_entitlement(
order.zhuangtai = MEMBER_PAID_STATUS
if not order.purchase_days:
order.purchase_days = purchase_days
if is_trial:
order.is_trial = True
order.save(update_fields=['zhuangtai', 'purchase_days', 'is_trial'])
_require_paid_order_for_benefits(order)
goumai_record, _ = _apply_huiyuan_entitlement(
order.yonghuid, order.huiyuan_id, huiyuan, club_id, is_trial, purchase_days,
)
if is_first_buy_any:
_add_first_buy_jifen(order.yonghuid)
order.zhuangtai = MEMBER_PAID_STATUS
order.save(update_fields=['zhuangtai'])
_apply_member_purchase_jifen(
order.yonghuid, is_trial=is_trial, huiyuan_id=order.huiyuan_id,
club_id=club_id, current_dingdan_id=order.dingdan_id,
)
huiyuan.goumai_cishu = (huiyuan.goumai_cishu or 0) + 1
huiyuan.save(update_fields=['goumai_cishu'])
_record_member_income(order, club_id)
try:
_apply_member_fenhong(
order, huiyuan,
formal_cishu=formal_cishu,
trial_guanshi_fc=trial_guanshi_fc,
trial_zuzhang_fc=trial_zuzhang_fc,
)
except Exception as exc:
logger.error(
'会员分红失败但会员已开通 dingdan=%s err=%s', dingdan_id, exc, exc_info=True,
)
_apply_member_fenhong(
order, huiyuan,
formal_cishu=formal_cishu,
trial_guanshi_fc=trial_guanshi_fc,
trial_zuzhang_fc=trial_zuzhang_fc,
)
logger.info(
'会员充值履约完成 dingdan=%s source=%s club=%s is_trial=%s formal_cishu=%s jine=%s',
@@ -689,14 +888,37 @@ def dashou_in_active_trial_period(yonghuid):
).exists()
def extend_member_entitlement_admin(yonghuid, huiyuan_id, days, club_id=None):
"""后台赠送正式会员(无 czjilu、无分红)"""
def extend_member_entitlement_admin(yonghuid, huiyuan_id, days, club_id=None, operator_note=''):
"""后台赠送正式会员:写 czjilu 审计记录,无支付、无分红、无积分"""
try:
huiyuan = Huiyuan.query.get(huiyuan_id=huiyuan_id)
except Huiyuan.DoesNotExist:
return None, '会员类型不存在'
days = max(1, int(days))
club_id = club_id or CLUB_ID_DEFAULT
record, _ = _apply_huiyuan_entitlement(
yonghuid, huiyuan_id, huiyuan, club_id, is_trial=False, purchase_days=days,
)
timestamp = str(int(timezone.now().timestamp() * 1000))[-12:]
rand_str = ''.join(random.choices(string.ascii_uppercase + string.digits, k=4))
dingdan_id = f'ADM{timestamp}{rand_str}'
note = (operator_note or '').strip()
shuoming = f'后台赠送会员 {huiyuan.jieshao} {days}'
if note:
shuoming = _truncate_shuoming(f'{shuoming} | {note}')
Czjilu.query.create(
dingdan_id=dingdan_id,
zhuangtai=MEMBER_PAID_STATUS,
yonghuid=yonghuid,
jine=Decimal('0'),
leixing=MEMBER_LEIXING,
shuoming=shuoming,
huiyuan_id=huiyuan_id,
club_id=club_id,
is_trial=False,
purchase_days=days,
)
logger.info('后台赠送会员 yonghu=%s huiyuan=%s days=%s dingdan=%s', yonghuid, huiyuan_id, days, dingdan_id)
return record, None