Files
Django/jituan/services/club_member_admin.py

481 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.
"""后台会员管理:全局 Huiyuan 定义 + 各俱乐部 ClubHuiyuanPrice 售价/分成/体验配置。"""
import logging
import random
import string
from decimal import Decimal
from django.db import transaction, IntegrityError
from jituan.constants import CLUB_ID_DEFAULT, DATA_SCOPE_ALL
from jituan.models import ClubHuiyuanPrice
from jituan.services.club_context import club_id_for_write, resolve_club_id_from_request, resolve_club_scope
from jituan.services.club_user_access import list_response_meta
from products.models import Huiyuan
logger = logging.getLogger(__name__)
CLUB_WRITE_SCOPE_MSG = '当前为集团汇总视图,请先在顶栏切换到具体俱乐部后再操作'
def club_write_blocked(request):
return resolve_club_scope(request) == DATA_SCOPE_ALL
def _price_row_to_dict(row):
if not row:
return {}
return {
'formal_days': int(row.formal_days or 30),
'trial_enabled': bool(row.trial_enabled),
'trial_price': str(row.trial_price or 0),
'trial_days': int(row.trial_days or 0),
'trial_guanshifc': str(row.trial_guanshifc or 0),
'trial_zuzhangfc': str(row.trial_zuzhangfc or 0),
'card_image': (row.card_image or '').strip(),
'card_bg': (row.card_bg or '').strip(),
}
def _format_member(m, price_row=None, club_id=None):
global_jiage = str(m.jiage)
global_guanshifc = str(m.guanshifc)
global_zuzhangfc = str(m.zuzhangfc)
item = {
'huiyuan_id': m.huiyuan_id,
'jieshao': m.jieshao,
'jtjieshao': m.jtjieshao,
'jiage': global_jiage,
'guanshifc': global_guanshifc,
'zuzhangfc': global_zuzhangfc,
'global_jiage': global_jiage,
'global_guanshifc': global_guanshifc,
'global_zuzhangfc': global_zuzhangfc,
'goumai_cishu': m.goumai_cishu,
'CreateTime': m.CreateTime.isoformat() if m.CreateTime else None,
'UpdateTime': m.UpdateTime.isoformat() if m.UpdateTime else None,
'bankuai_id': m.bankuai_id,
'club_enabled': True,
'price_source': 'global',
'formal_days': 30,
'trial_enabled': False,
'trial_price': '0.00',
'trial_days': 0,
'trial_guanshifc': '0.00',
'trial_zuzhangfc': '0.00',
'is_bundle': bool(getattr(m, 'is_bundle', False)),
'included_huiyuan_ids': [],
}
if price_row is not None:
item['jiage'] = str(price_row.jiage)
item['guanshifc'] = str(price_row.guanshifc)
item['zuzhangfc'] = str(price_row.zuzhangfc)
item['club_enabled'] = bool(price_row.is_enabled)
item['price_source'] = 'club'
item.update(_price_row_to_dict(price_row))
if item.get('is_bundle') and club_id:
from jituan.services.huiyuan_bundle import get_bundle_included_ids
item['included_huiyuan_ids'] = get_bundle_included_ids(club_id, m.huiyuan_id)
return item
def count_club_member_sales(club_id, huiyuan_id, formal_only=True):
"""本俱乐部已支付会员单数(用于后台展示,非全局 goumai_cishu"""
from products.models import Czjilu
qs = Czjilu.query.filter(
club_id=club_id,
huiyuan_id=huiyuan_id,
leixing=1,
zhuangtai=3,
)
if formal_only:
qs = qs.filter(is_trial=False)
return qs.count()
def build_member_list_payload(request):
scope = resolve_club_scope(request)
club_id = resolve_club_id_from_request(request)
# P1 优化:限制查询字段,避免加载不必要的大字段
members = list(Huiyuan.query.all().only(
'huiyuan_id', 'jieshao', 'jtjieshao', 'jiage',
'guanshifc', 'zuzhangfc', 'goumai_cishu', 'bankuai_id',
'CreateTime', 'is_bundle',
).order_by('-CreateTime'))
member_list = []
price_map = {}
if scope != DATA_SCOPE_ALL:
price_map = {
r.huiyuan_id: r
for r in ClubHuiyuanPrice.query.filter(club_id=club_id)
}
# P1 优化:批量预取本俱乐部各会员的已支付单数,避免循环内 N+1
_sales_map = {}
if scope != DATA_SCOPE_ALL and members:
from products.models import Czjilu
from django.db.models import Count
_sales_rows = Czjilu.query.filter(
club_id=club_id,
huiyuan_id__in=[m.huiyuan_id for m in members],
leixing=1,
zhuangtai=3,
is_trial=False,
).values('huiyuan_id').annotate(cnt=Count('id'))
_sales_map = {r['huiyuan_id']: r['cnt'] for r in _sales_rows}
if scope == DATA_SCOPE_ALL:
for m in members:
member_list.append(_format_member(m))
else:
# 有本俱乐部专属价:只展示本店上架项;尚无专属价:回落集团默认会员定义。
# 绝不混入其他俱乐部的 ClubHuiyuanPrice。
has_club_prices = bool(price_map)
for m in members:
row = price_map.get(m.huiyuan_id)
if has_club_prices:
if row is None or not row.is_enabled:
continue
item = _format_member(m, row, club_id=club_id)
else:
item = _format_member(m, club_id=club_id)
item['goumai_cishu'] = _sales_map.get(m.huiyuan_id, 0)
member_list.append(item)
return {
'list': member_list,
'club_id': club_id,
'scope': scope,
'price_note': (
f'当前俱乐部 {club_id} 专属售价;未配置专属价时展示集团默认会员。'
if scope != DATA_SCOPE_ALL else
'集团汇总:展示全局默认价;切换具体俱乐部可配置售价与体验会员。'
),
**list_response_meta(request),
}
def build_member_catalog_payload(request):
"""集团会员类型 + 本俱乐部未上架的可选列表。"""
club_id = resolve_club_id_from_request(request)
enabled_ids = set(
ClubHuiyuanPrice.query.filter(club_id=club_id, is_enabled=True).values_list('huiyuan_id', flat=True)
)
# P1 优化DB 端排除已上架会员,限制字段,避免全表加载 + Python 过滤
catalog_qs = Huiyuan.query.all()
if enabled_ids:
catalog_qs = catalog_qs.exclude(huiyuan_id__in=list(enabled_ids))
catalog = list(
catalog_qs.only('huiyuan_id', 'jieshao', 'jtjieshao', 'is_bundle')
.order_by('-CreateTime')
.values('huiyuan_id', 'jieshao', 'jtjieshao', 'is_bundle')
)
# values 返回 dict 列表,需补 is_bundle bool 化
for row in catalog:
row['is_bundle'] = bool(row.get('is_bundle'))
return {'club_id': club_id, 'catalog': catalog}
def _parse_trial_fields(data, jiage, guanshifc, zuzhangfc):
formal_days = int(data.get('formal_days') or 30)
trial_enabled = bool(data.get('trial_enabled'))
trial_price = Decimal(str(data.get('trial_price') or 0))
trial_days = int(data.get('trial_days') or 0)
trial_guanshifc = Decimal(str(data.get('trial_guanshifc') or 0))
trial_zuzhangfc = Decimal(str(data.get('trial_zuzhangfc') or 0))
if formal_days < 1 or formal_days > 3650:
raise ValueError('正式会员天数无效')
if trial_enabled:
if trial_price <= 0 or trial_days <= 0:
raise ValueError('开启体验会员须配置体验价格与天数')
if trial_guanshifc + trial_zuzhangfc > trial_price:
raise ValueError('体验管事+组长分成不能大于体验价格')
if guanshifc + zuzhangfc > jiage:
raise ValueError('管事分成+组长分成不能大于会员价格')
return {
'formal_days': formal_days,
'trial_enabled': trial_enabled,
'trial_price': trial_price,
'trial_days': trial_days,
'trial_guanshifc': trial_guanshifc,
'trial_zuzhangfc': trial_zuzhangfc,
}
def update_member_for_club(request, huiyuan_id, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, extra=None):
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
club_id = club_id_for_write(request)
extra = extra or {}
try:
member_preview = Huiyuan.objects.get(huiyuan_id=huiyuan_id)
if getattr(member_preview, 'is_bundle', False):
extra = dict(extra)
extra['trial_enabled'] = False
except Huiyuan.DoesNotExist:
pass
try:
trial_fields = _parse_trial_fields(extra, jiage, guanshifc, zuzhangfc)
except ValueError as e:
return None, str(e)
with transaction.atomic():
try:
member = Huiyuan.objects.select_for_update().get(huiyuan_id=huiyuan_id)
except Huiyuan.DoesNotExist:
return None, '会员不存在'
member.jieshao = jieshao
member.jtjieshao = jtjieshao
member.save(update_fields=['jieshao', 'jtjieshao', 'UpdateTime'])
row, created = ClubHuiyuanPrice.query.get_or_create(
club_id=club_id,
huiyuan_id=huiyuan_id,
defaults={
'jiage': jiage,
'guanshifc': guanshifc,
'zuzhangfc': zuzhangfc,
'is_enabled': True,
'bankuai_id': member.bankuai_id,
**trial_fields,
},
)
if not created:
row.jiage = jiage
row.guanshifc = guanshifc
row.zuzhangfc = zuzhangfc
row.is_enabled = True
for k, v in trial_fields.items():
setattr(row, k, v)
if 'card_image' in extra:
row.card_image = (extra.get('card_image') or '').strip()
if 'card_bg' in extra:
row.card_bg = (extra.get('card_bg') or '').strip()
row.save()
if 'included_huiyuan_ids' in extra and getattr(member, 'is_bundle', False):
from jituan.services.huiyuan_bundle import save_bundle_includes
_, err = save_bundle_includes(club_id, huiyuan_id, extra.get('included_huiyuan_ids'))
if err:
return None, err
if club_id == CLUB_ID_DEFAULT:
member.jiage = jiage
member.guanshifc = guanshifc
member.zuzhangfc = zuzhangfc
member.save(update_fields=['jiage', 'guanshifc', 'zuzhangfc', 'UpdateTime'])
return {'huiyuan_id': huiyuan_id}, None
MEMBER_CARD_FOLDER = 'beijing/huiyuan'
def upload_member_card_image(request, huiyuan_id, field, file_obj):
"""上传会员充值页卡片图,写入 club_huiyuan_price.card_image / card_bg。
每次上传生成新相对路径并删除旧图,避免「同名覆盖但 CDN/浏览器仍显示旧图」。
"""
import os
import uuid
from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
if field not in ('card_image', 'card_bg'):
return None, '无效的图字段'
ok, msg = validate_image(file_obj)
if not ok:
return None, msg
club_id = club_id_for_write(request)
row = ClubHuiyuanPrice.query.filter(club_id=club_id, huiyuan_id=huiyuan_id).first()
if not row:
return None, '请先在当前俱乐部上架该会员'
ext = os.path.splitext(getattr(file_obj, 'name', '') or '')[1].lower()
if ext not in ('.png', '.jpg', '.jpeg', '.webp', '.gif'):
ext = '.png'
# 带时间戳+uuid保证 URL 变化,前端/CDN 不会命中旧缓存
stamp = uuid.uuid4().hex[:12]
rel_path = f'{MEMBER_CARD_FOLDER}/{club_id}/{huiyuan_id}_{field}_{stamp}{ext}'
if hasattr(file_obj, 'seek'):
try:
file_obj.seek(0)
except Exception:
pass
if not upload_to_oss(file_obj, rel_path):
return None, '上传 OSS 失败'
old_path = (getattr(row, field, None) or '').strip()
setattr(row, field, rel_path)
row.save(update_fields=[field, 'UpdateTime'])
if old_path and old_path != rel_path:
try:
delete_from_oss(old_path)
except Exception as e:
logger.warning('删除旧会员卡片图失败 club=%s hy=%s field=%s path=%s err=%s',
club_id, huiyuan_id, field, old_path, e)
return {field: rel_path, 'huiyuan_id': huiyuan_id}, None
def enable_member_for_club(request, huiyuan_id, jiage, guanshifc, zuzhangfc, extra=None):
"""俱乐部上架集团会员类型。"""
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
club_id = club_id_for_write(request)
extra = extra or {}
try:
member = Huiyuan.query.get(huiyuan_id=huiyuan_id)
except Huiyuan.DoesNotExist:
return None, '集团会员类型不存在'
if getattr(member, 'is_bundle', False):
extra = dict(extra or {})
extra['trial_enabled'] = False
try:
jiage = Decimal(str(jiage))
guanshifc = Decimal(str(guanshifc))
zuzhangfc = Decimal(str(zuzhangfc))
if jiage <= 0:
raise ValueError
trial_fields = _parse_trial_fields(extra, jiage, guanshifc, zuzhangfc)
except ValueError as e:
return None, str(e) if str(e) else '金额或体验配置错误'
row, created = ClubHuiyuanPrice.query.get_or_create(
club_id=club_id,
huiyuan_id=huiyuan_id,
defaults={
'jiage': jiage,
'guanshifc': guanshifc,
'zuzhangfc': zuzhangfc,
'is_enabled': True,
'bankuai_id': member.bankuai_id,
**trial_fields,
},
)
if not created:
row.jiage = jiage
row.guanshifc = guanshifc
row.zuzhangfc = zuzhangfc
row.is_enabled = True
for k, v in trial_fields.items():
setattr(row, k, v)
row.save()
if getattr(member, 'is_bundle', False) and extra.get('included_huiyuan_ids') is not None:
from jituan.services.huiyuan_bundle import save_bundle_includes
_, err = save_bundle_includes(club_id, huiyuan_id, extra.get('included_huiyuan_ids'))
if err:
return None, err
return {'huiyuan_id': huiyuan_id}, None
def disable_member_for_club(request, huiyuan_id):
"""俱乐部软下架。"""
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
club_id = club_id_for_write(request)
updated = ClubHuiyuanPrice.objects.filter(
club_id=club_id, huiyuan_id=huiyuan_id, is_enabled=True,
).update(is_enabled=False)
if not updated:
return None, '该会员在本俱乐部未上架或已下架'
return {'huiyuan_id': huiyuan_id}, None
def add_global_member(jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, bankuai_id=None, is_bundle=False):
"""集团创建全局会员类型(仅集团后台)。"""
def generate_huiyuan_id():
while True:
new_id = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
if not Huiyuan.query.filter(huiyuan_id=new_id).exists():
return new_id
try:
with transaction.atomic():
new_id = generate_huiyuan_id()
create_kwargs = dict(
huiyuan_id=new_id,
jieshao=jieshao,
jiage=jiage,
guanshifc=guanshifc,
zuzhangfc=zuzhangfc,
jtjieshao=jtjieshao or '',
goumai_cishu=0,
is_bundle=bool(is_bundle),
)
if bankuai_id is not None:
create_kwargs['bankuai_id'] = bankuai_id
member = Huiyuan.query.create(**create_kwargs)
except IntegrityError as e:
logger.exception('创建全局会员失败')
return None, f'创建失败:{e}'
except Exception as e:
logger.exception('创建全局会员失败')
return None, f'创建失败:{e}'
return {'huiyuan_id': member.huiyuan_id}, None
def create_bundle_member_for_club(
request, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao,
formal_days=30, included_huiyuan_ids=None,
):
"""俱乐部一键创建总会员:新建全局 is_bundle 类型 + 本俱乐部上架 + 配置包含子会员。"""
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
included_huiyuan_ids = included_huiyuan_ids or []
if not included_huiyuan_ids:
return None, '总会员请至少选择一个包含的子会员'
try:
jiage = Decimal(str(jiage))
guanshifc = Decimal(str(guanshifc))
zuzhangfc = Decimal(str(zuzhangfc))
if jiage <= 0:
raise ValueError
trial_fields = _parse_trial_fields(
{'formal_days': formal_days, 'trial_enabled': False},
jiage, guanshifc, zuzhangfc,
)
except ValueError as e:
return None, str(e) if str(e) else '金额或天数配置错误'
with transaction.atomic():
data, err = add_global_member(
jieshao, jiage, guanshifc, zuzhangfc, jtjieshao or '', is_bundle=True,
)
if err:
return None, err
extra = {
**trial_fields,
'included_huiyuan_ids': included_huiyuan_ids,
}
_, err = enable_member_for_club(
request, data['huiyuan_id'], jiage, guanshifc, zuzhangfc, extra=extra,
)
if err:
return None, err
return data, None
# 兼容旧名:俱乐部不应再调用
def add_member_for_club(request, jieshao, jiage, guanshifc, zuzhangfc, jtjieshao, bankuai_id=None):
if club_write_blocked(request):
return None, CLUB_WRITE_SCOPE_MSG
return None, '俱乐部不可新建会员类型,请从集团会员 catalog 上架'