From 60d8b526937b61275a8c557d335b4f13dccecf13 Mon Sep 17 00:00:00 2001 From: XingQue Date: Tue, 21 Jul 2026 02:37:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=82=B9=E5=8D=95=E7=AB=AF=E9=82=80?= =?UTF-8?q?=E8=AF=B7=E7=BB=91=E5=AE=9A=E4=B8=8E=E5=8F=AA=E7=9C=8B=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E5=95=86=E5=93=81=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用 ClubMiniappIcon 增加点单端海报背景;新增 C 端邀请码/换绑店铺接口;hqsp 支持 zhi_kan_shenhe,不影响支付抢单分红。 Co-authored-by: Cursor --- backend/views/shops.py | 27 +++- config/urls.py | 3 +- config/views/__init__.py | 4 +- config/views/qrcode_poster.py | 121 ++++++++++++++ jituan/services/miniapp_assets.py | 4 + shop/migrations/0003_zhi_kan_shenhe.py | 16 ++ shop/models.py | 2 + shop/views/shop_base_views.py | 17 +- users/migrations/0006_yonghu_c_yaoqing.py | 42 +++++ users/models.py | 28 ++++ users/services/__init__.py | 1 + users/services/c_invite.py | 184 ++++++++++++++++++++++ users/urls.py | 5 + users/views/c_invite.py | 168 ++++++++++++++++++++ 14 files changed, 615 insertions(+), 7 deletions(-) create mode 100644 shop/migrations/0003_zhi_kan_shenhe.py create mode 100644 users/migrations/0006_yonghu_c_yaoqing.py create mode 100644 users/services/__init__.py create mode 100644 users/services/c_invite.py create mode 100644 users/views/c_invite.py diff --git a/backend/views/shops.py b/backend/views/shops.py index e9366c1..264d768 100644 --- a/backend/views/shops.py +++ b/backend/views/shops.py @@ -424,12 +424,14 @@ class ShopPublicTypeAndAuditView(APIView): # ---------- 获取审核模式配置 ---------- audit_config = DianpuShangpinShenheShezhi.query.filter(id=1).first() kaiqi_shenhe = audit_config.kaiqi_shenhe if audit_config else False + zhi_kan_shenhe = getattr(audit_config, 'zhi_kan_shenhe', True) if audit_config else True return Response({ 'code': 0, 'data': { 'public_types': type_list, - 'kaiqi_shenhe': kaiqi_shenhe + 'kaiqi_shenhe': kaiqi_shenhe, + 'zhi_kan_shenhe': bool(zhi_kan_shenhe), } }) @@ -622,6 +624,8 @@ class ShopProductModifyView(APIView): if action == 'update_audit_mode': return self._update_audit_mode(request, user_perms) + elif action == 'update_zhi_kan_shenhe': + return self._update_zhi_kan_shenhe(request, user_perms) elif action == 'update_mapping_status': return self._update_mapping_status(request, user_perms) else: @@ -629,7 +633,7 @@ class ShopProductModifyView(APIView): @transaction.atomic # ✅ 事务修复点 def _update_audit_mode(self, request, user_perms): - if not has_perm_code(permissions, '1199ab'): + if not has_perm_code(user_perms, '1199ab'): return Response({'code': 403, 'msg': '无权限修改审核模式'}) kaiqi = request.data.get('kaiqi_shenhe') @@ -645,6 +649,25 @@ class ShopProductModifyView(APIView): logger.exception("更新审核模式失败") return Response({'code': 500, 'msg': '服务器错误'}) + @transaction.atomic + def _update_zhi_kan_shenhe(self, request, user_perms): + """点单端:未绑店用户是否按身份只看审核专用商品。""" + if not has_perm_code(user_perms, '1199ab'): + return Response({'code': 403, 'msg': '无权限修改只看审核开关'}) + + val = request.data.get('zhi_kan_shenhe') + if val is None: + return Response({'code': 400, 'msg': '缺少参数 zhi_kan_shenhe'}) + + try: + config, _ = DianpuShangpinShenheShezhi.objects.select_for_update().get_or_create(id=1) + config.zhi_kan_shenhe = bool(val) + config.save(update_fields=['zhi_kan_shenhe']) + return Response({'code': 0, 'msg': '只看审核开关已更新'}) + except Exception: + logger.exception("更新只看审核开关失败") + return Response({'code': 500, 'msg': '服务器错误'}) + @transaction.atomic def _update_mapping_status(self, request, user_perms): mapping_id = request.data.get('mapping_id') diff --git a/config/urls.py b/config/urls.py index 3b6d7fe..e819b4d 100644 --- a/config/urls.py +++ b/config/urls.py @@ -3,7 +3,7 @@ from django.urls import path from .views import ShangpinGonggaoView, AdminUpdateConfigView, AdminUploadImageView, AdminConfigQueryView, \ DashouPaihangView,GetAdminZhanghaoView, GetXiugaiJiluView,ShangjiaTianjiaMobanView, ShangjiaMobanListView, \ ShangjiaGengxinMobanView, ShangjiaShanchuMobanView, ShangjiaGenerateLinkView,KehuGetDingdanLianjieView, \ - KehuTianxieDingdanView, GuanZhuAListView, ZuzhangHaibaoView,GuanshiQRCodeView,\ + KehuTianxieDingdanView, GuanZhuAListView, ZuzhangHaibaoView,GuanshiQRCodeView, CYaoqingQRCodeView,\ GetDynamicConfigView, HaibaoPeizhiView, PopupConfigView, GetWithdrawModeView,CheckPhoneAuthView,\ ShangjiaLianjieListView, HuashuHqView, HuashuMatchView @@ -31,6 +31,7 @@ urlpatterns = [ path('gzal', GuanZhuAListView.as_view(), name='关注阿龙'), path('guanshiewm', GuanshiQRCodeView.as_view(), name='管事生成二维码'), path('zuzhanghb', ZuzhangHaibaoView.as_view(), name='组长二维码'), + path('cyaoqingewm', CYaoqingQRCodeView.as_view(), name='点单端邀请二维码'), path('qdpzhq', GetDynamicConfigView.as_view(), name='小程序获取配置'), path('haibaopz', HaibaoPeizhiView.as_view(), name='推广海报配置'), diff --git a/config/views/__init__.py b/config/views/__init__.py index 7ebfc55..d3e41b7 100644 --- a/config/views/__init__.py +++ b/config/views/__init__.py @@ -38,7 +38,7 @@ from .merchant_links import ( IPUtils, OrderLinkThrottle, ) from .qrcode_poster import ( - GuanZhuAListView, GuanshiQRCodeView, ZuzhangHaibaoView, + GuanZhuAListView, GuanshiQRCodeView, ZuzhangHaibaoView, CYaoqingQRCodeView, ) from .scripts import ( HuashuHqView, HuashuMatchView, @@ -65,7 +65,7 @@ __all__ = [ 'KehuGetDingdanLianjieView', 'KehuTianxieDingdanView', 'IPUtils', 'OrderLinkThrottle', # qrcode_poster - 'GuanZhuAListView', 'GuanshiQRCodeView', 'ZuzhangHaibaoView', + 'GuanZhuAListView', 'GuanshiQRCodeView', 'ZuzhangHaibaoView', 'CYaoqingQRCodeView', # scripts 'HuashuHqView', 'HuashuMatchView', 'MiniappScriptHoutaiListView', 'MiniappScriptHoutaiModifyView', diff --git a/config/views/qrcode_poster.py b/config/views/qrcode_poster.py index 77cab56..d134cf3 100644 --- a/config/views/qrcode_poster.py +++ b/config/views/qrcode_poster.py @@ -351,9 +351,130 @@ class GuanshiQRCodeView(APIView): return None +class CYaoqingQRCodeView(APIView): + """ + 点单端用户邀请小程序码。 + POST /peizhi/cyaoqingewm + """ + permission_classes = [permissions.IsAuthenticated] + def post(self, request): + current_user = request.user + try: + from jituan.services.club_context import resolve_effective_club_id + from utils.weixin_token import get_club_mini_access_token, is_weixin_token_invalid + from users.services.c_invite import ensure_c_invite_code + from users.models import YonghuCYaoqing + club_id = resolve_effective_club_id(request, current_user) + profile, invite_code, _ = ensure_c_invite_code(current_user) + access_token = get_club_mini_access_token(club_id) + if not access_token: + return Response( + {'code': 500, 'message': '获取微信access_token失败', 'data': None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + encoded_invite = urllib.parse.quote(invite_code, safe='') + page_path = f'pages/index/index?inviteCode={encoded_invite}' + post_data = { + 'path': page_path, + 'width': 430, + 'auto_color': False, + 'line_color': {'r': 0, 'g': 0, 'b': 0}, + } + + def _request_qr(token): + try: + return requests.post( + f'https://api.weixin.qq.com/wxa/getwxacode?access_token={token}', + json=post_data, + timeout=10, + ) + except Exception as e: + logger.error('请求微信接口异常: %s', e, exc_info=True) + return None + + wx_resp = _request_qr(access_token) + if wx_resp is None: + return Response( + {'code': 500, 'message': '调用微信服务失败', 'data': None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + if wx_resp.headers.get('content-type', '').startswith('application/json'): + try: + err = wx_resp.json() + if is_weixin_token_invalid(err.get('errcode'), err.get('errmsg', '')): + access_token = get_club_mini_access_token(club_id, force_refresh=True) + if access_token: + wx_resp = _request_qr(access_token) + except Exception: + pass + + if wx_resp.status_code != 200: + errmsg = '未知错误' + try: + errmsg = wx_resp.json().get('errmsg', errmsg) + except Exception: + pass + return Response( + {'code': 500, 'message': f'生成二维码失败: {errmsg}', 'data': None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + content_type = wx_resp.headers.get('content-type', '') + if 'image' not in content_type: + try: + errmsg = wx_resp.json().get('errmsg', '未知错误') + except Exception: + errmsg = '生成二维码失败' + return Response( + {'code': 500, 'message': f'生成二维码失败: {errmsg}', 'data': None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + timestamp = int(time.time()) + filename = f'{current_user.UserUID}_{timestamp}.png' + oss_path = f'erweima/cuser/{club_id}/{filename}' + file_obj = io.BytesIO(wx_resp.content) + full_url = upload_to_oss(file_obj, oss_path) + if not full_url: + return Response( + {'code': 500, 'message': '图片上传失败', 'data': None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + cos_domain = getattr(settings, 'COS_DOMAIN', '') + if cos_domain and full_url.startswith(cos_domain): + relative_path = full_url[len(cos_domain):].lstrip('/') + else: + relative_path = oss_path + + old_url = profile.invite_qrcode_url + if old_url: + try: + delete_from_oss(old_url) + except Exception as e: + logger.warning('删除旧 C 端二维码失败: %s', e) + + with transaction.atomic(): + locked = YonghuCYaoqing.objects.select_for_update().get(pk=profile.pk) + locked.invite_qrcode_url = relative_path + locked.save(update_fields=['invite_qrcode_url', 'UpdateTime']) + + return Response({ + 'code': 0, + 'message': 'success', + 'data': {'url': relative_path, 'yaoqingma': invite_code}, + }) + except Exception as e: + logger.error('生成点单端邀请二维码失败: %s', e, exc_info=True) + return Response( + {'code': 500, 'message': '服务器内部错误', 'data': None}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) class ZuzhangHaibaoView(APIView): diff --git a/jituan/services/miniapp_assets.py b/jituan/services/miniapp_assets.py index b73c5b8..8448374 100644 --- a/jituan/services/miniapp_assets.py +++ b/jituan/services/miniapp_assets.py @@ -76,6 +76,7 @@ MINIAPP_ICON_META = [ ('icon_refresh', '通用刷新'), ('guanshi_poster_bg', '管事推广海报背景'), ('zuzhang_poster_bg', '组长推广海报背景'), + ('cuser_poster_bg', '点单端推广海报背景'), ] MINIAPP_ICON_KEYS = [k for k, _ in MINIAPP_ICON_META] @@ -136,14 +137,17 @@ MINIAPP_ICON_DEFAULTS['fighter_mine_switch'] = f'{ICON_FOLDER}/fighter_mine_swit MINIAPP_ICON_DEFAULTS['mine_pindao'] = 'beijing/tubiao/grzx_pindao.jpg' MINIAPP_ICON_DEFAULTS['guanshi_poster_bg'] = 'beijing/haibaobeijing.jpg' MINIAPP_ICON_DEFAULTS['zuzhang_poster_bg'] = 'beijing/zuzhangbeijing.jpg' +MINIAPP_ICON_DEFAULTS['cuser_poster_bg'] = 'beijing/haibaobeijing.jpg' POSTER_BG_FIELDS = { 'guanshi_bg': 'guanshi_poster_bg', 'zuzhang_bg': 'zuzhang_poster_bg', + 'cuser_bg': 'cuser_poster_bg', } POSTER_BG_DEFAULTS = { 'guanshi_bg': 'beijing/haibaobeijing.jpg', 'zuzhang_bg': 'beijing/zuzhangbeijing.jpg', + 'cuser_bg': 'beijing/haibaobeijing.jpg', } POSTER_FOLDER = 'beijing/haibao' POSTER_ICON_KEYS = frozenset(POSTER_BG_FIELDS.values()) diff --git a/shop/migrations/0003_zhi_kan_shenhe.py b/shop/migrations/0003_zhi_kan_shenhe.py new file mode 100644 index 0000000..c41237c --- /dev/null +++ b/shop/migrations/0003_zhi_kan_shenhe.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0002_dianpu_club_id'), + ] + + operations = [ + migrations.AddField( + model_name='dianpushangpinshenheshezhi', + name='zhi_kan_shenhe', + field=models.BooleanField(default=True, verbose_name='是否只看审核商品'), + ), + ] diff --git a/shop/models.py b/shop/models.py index 95280e7..42714ea 100644 --- a/shop/models.py +++ b/shop/models.py @@ -215,6 +215,8 @@ class ShangpinLeixingDianpu(QModel): class DianpuShangpinShenheShezhi(QModel): """店铺商品审核模式全局配置(仅一条记录)""" kaiqi_shenhe = models.BooleanField(default=False, verbose_name='是否开启商品审核模式') + # True=未注册打手等特殊身份时只看审核专用商品;False=未绑店一律看正常公共商品 + zhi_kan_shenhe = models.BooleanField(default=True, verbose_name='是否只看审核商品') CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') diff --git a/shop/views/shop_base_views.py b/shop/views/shop_base_views.py index d472f30..24f01f7 100644 --- a/shop/views/shop_base_views.py +++ b/shop/views/shop_base_views.py @@ -169,13 +169,26 @@ class ShopGoodsView(APIView): if dianpu.zhuangtai == 1 and shop_club == club_id: data = self._get_shop_goods(dianpu) else: - # 店铺封禁或不属于当前俱乐部 → 公共商品 + # 店铺封禁或不属于当前俱乐部 → 公共正常商品 data = self._get_public_goods(True) else: - data = self._get_public_goods(has_special) + # 未绑店:受「是否只看审核」开关控制 + # 开启 → 有特殊身份看正常(=1),否则看审核专用(=2) + # 关闭 → 一律看正常公共商品(与身份无关) + use_normal = True + if self._is_zhi_kan_shenhe_enabled(): + use_normal = has_special + data = self._get_public_goods(use_normal) return Response({'code': 200, 'data': data}) + def _is_zhi_kan_shenhe_enabled(self): + """全局开关:未绑店用户是否按身份只看审核专用商品。默认开启以保持历史行为。""" + config = DianpuShangpinShenheShezhi.query.filter(id=1).first() + if not config: + return True + return bool(getattr(config, 'zhi_kan_shenhe', True)) + def _has_special_identity(self, user_main): """判断用户是否拥有打手/商家/管事/组长任一身份""" return (UserDashou.query.filter(user=user_main).exists() or diff --git a/users/migrations/0006_yonghu_c_yaoqing.py b/users/migrations/0006_yonghu_c_yaoqing.py new file mode 100644 index 0000000..0e5135a --- /dev/null +++ b/users/migrations/0006_yonghu_c_yaoqing.py @@ -0,0 +1,42 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0005_dashou_zengsong_huiyuan'), + ] + + operations = [ + migrations.CreateModel( + name='YonghuCYaoqing', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('yaoqingma', models.CharField(blank=True, db_index=True, max_length=20, null=True, verbose_name='邀请码')), + ('invite_qrcode_url', models.CharField(blank=True, max_length=500, null=True, verbose_name='邀请小程序码URL')), + ('yaogingshuliang', models.IntegerField(default=0, verbose_name='邀请人数')), + ('yaoqingren', models.CharField(blank=True, db_index=True, max_length=7, null=True, verbose_name='邀请人UserUID')), + ('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('user', models.OneToOneField(db_column='UserUUID', on_delete=django.db.models.deletion.CASCADE, related_name='CYaoqingProfile', to='users.user', verbose_name='关联用户')), + ], + options={ + 'verbose_name': '点单端用户邀请', + 'verbose_name_plural': '点单端用户邀请', + 'db_table': 'yonghu_c_yaoqing', + }, + ), + migrations.AddIndex( + model_name='yonghucyaoqing', + index=models.Index(fields=['user'], name='yonghu_c_ya_user_id_idx'), + ), + migrations.AddIndex( + model_name='yonghucyaoqing', + index=models.Index(fields=['yaoqingma'], name='yonghu_c_ya_yaoqing_idx'), + ), + migrations.AddIndex( + model_name='yonghucyaoqing', + index=models.Index(fields=['yaoqingren'], name='yonghu_c_ya_yaoqingr_idx'), + ), + ] diff --git a/users/models.py b/users/models.py index 567c360..d35048e 100644 --- a/users/models.py +++ b/users/models.py @@ -106,6 +106,34 @@ class UserBoss(QModel): indexes = [models.Index(fields=['user']), ] +# ==================== 点单端 C 用户邀请 ==================== +class YonghuCYaoqing(QModel): + """点单端用户邀请关系(邀请码 / 邀请人 / 邀请人数)""" + user = models.OneToOneField( + 'users.User', + on_delete=models.CASCADE, + related_name='CYaoqingProfile', + verbose_name='关联用户', + db_column='UserUUID', + ) + yaoqingma = models.CharField(max_length=20, null=True, blank=True, db_index=True, verbose_name='邀请码') + invite_qrcode_url = models.CharField(max_length=500, null=True, blank=True, verbose_name='邀请小程序码URL') + yaogingshuliang = models.IntegerField(default=0, verbose_name='邀请人数') + yaoqingren = models.CharField(max_length=7, null=True, blank=True, db_index=True, verbose_name='邀请人UserUID') + CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + db_table = 'yonghu_c_yaoqing' + verbose_name = '点单端用户邀请' + verbose_name_plural = verbose_name + indexes = [ + models.Index(fields=['user']), + models.Index(fields=['yaoqingma']), + models.Index(fields=['yaoqingren']), + ] + + # ==================== 打手扩展表 ==================== class UserDashou(QModel): user = models.OneToOneField('users.User', on_delete=models.CASCADE, related_name='DashouProfile', diff --git a/users/services/__init__.py b/users/services/__init__.py new file mode 100644 index 0000000..9801102 --- /dev/null +++ b/users/services/__init__.py @@ -0,0 +1 @@ +# users.services package diff --git a/users/services/c_invite.py b/users/services/c_invite.py new file mode 100644 index 0000000..795446a --- /dev/null +++ b/users/services/c_invite.py @@ -0,0 +1,184 @@ +"""点单端 C 用户邀请:邀请码、换绑、店铺跟随。""" +from django.db import transaction +from django.db.models import F + +from shop.models import Dianpu, YonghuDianpuBangding +from users.models import YonghuCYaoqing +from users.business_models import User +from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode + +import logging + +logger = logging.getLogger(__name__) + + +def get_or_create_c_yaoqing(user): + """获取或创建用户的点单端邀请扩展记录。""" + profile, _ = YonghuCYaoqing.objects.get_or_create(user=user) + return profile + + +def ensure_c_invite_code(user): + """确保用户有邀请码,返回 (profile, yaoqingma, is_new)。""" + profile = get_or_create_c_yaoqing(user) + existing = (profile.yaoqingma or '').strip() + if existing: + return profile, existing, False + + new_code = CreateInvitationCode(str(user.UserUID)) + if not VerifyInvitationCode(new_code): + raise ValueError('邀请码生成失败') + + # 极低概率冲突时再生成一次 + if YonghuCYaoqing.query.filter(yaoqingma=new_code).exclude(pk=profile.pk).exists(): + new_code = CreateInvitationCode(str(user.UserUID) + 'R') + if not VerifyInvitationCode(new_code): + raise ValueError('邀请码生成失败') + + profile.yaoqingma = new_code + profile.save(update_fields=['yaoqingma', 'UpdateTime']) + return profile, new_code, True + + +def _unbind_shop(user_locked): + """解除用户店铺绑定并扣减旧店绑定人数。""" + binding = ( + YonghuDianpuBangding.objects.select_for_update() + .filter(yonghu=user_locked) + .select_related('dianpu') + .first() + ) + if not binding: + return None + old = binding.dianpu + old.bangding_yonghushu = F('bangding_yonghushu') - 1 + old.save(update_fields=['bangding_yonghushu']) + binding.delete() + return old.id + + +def _bind_shop(user_locked, dianpu_locked): + """将用户绑定到指定店铺(已存在则换绑)。返回 (changed, msg)。""" + if dianpu_locked.zhuangtai != 1: + return False, '邀请人店铺已被封禁' + + try: + existing = YonghuDianpuBangding.objects.select_for_update().get(yonghu=user_locked) + if existing.dianpu_id == dianpu_locked.id: + return False, '已绑定该店铺' + old = existing.dianpu + old.bangding_yonghushu = F('bangding_yonghushu') - 1 + old.save(update_fields=['bangding_yonghushu']) + existing.dianpu = dianpu_locked + existing.save(update_fields=['dianpu', 'UpdateTime']) + except YonghuDianpuBangding.DoesNotExist: + YonghuDianpuBangding.query.create(yonghu=user_locked, dianpu=dianpu_locked) + + dianpu_locked.bangding_yonghushu = F('bangding_yonghushu') + 1 + dianpu_locked.save(update_fields=['bangding_yonghushu']) + return True, '店铺已跟随邀请人换绑' + + +def sync_shop_from_inviter(invitee_user, inviter_user): + """ + 按邀请人店铺同步被邀请人店铺: + - 邀请人有正常店铺 → 被邀请人绑到同一店 + - 邀请人无店铺 → 被邀请人解绑店铺(回落平台公共商品逻辑) + 须在 transaction.atomic 内调用。 + """ + invitee_locked = User.objects.select_for_update().get(UserUUID=invitee_user.UserUUID) + inviter_locked = User.objects.select_for_update().get(UserUUID=inviter_user.UserUUID) + + inviter_binding = ( + YonghuDianpuBangding.objects.select_for_update() + .filter(yonghu=inviter_locked) + .select_related('dianpu') + .first() + ) + + if inviter_binding and inviter_binding.dianpu.zhuangtai == 1: + dianpu_locked = Dianpu.objects.select_for_update().get(id=inviter_binding.dianpu_id) + changed, msg = _bind_shop(invitee_locked, dianpu_locked) + return {'shop_synced': True, 'dianpu_id': dianpu_locked.id, 'changed': changed, 'msg': msg} + + old_id = _unbind_shop(invitee_locked) + return { + 'shop_synced': False, + 'dianpu_id': None, + 'changed': bool(old_id), + 'msg': '邀请人未绑定店铺,已解除店铺绑定' if old_id else '邀请人未绑定店铺', + } + + +def bind_c_inviter_by_code(invitee_user, invite_code): + """ + 用邀请码绑定/换绑邀请人,并同步店铺。 + 返回 dict: code/msg/data + """ + code = (invite_code or '').strip() + if not code: + return {'code': 400, 'msg': '邀请码不能为空', 'data': None} + if len(code) > 100: + return {'code': 400, 'msg': '邀请码格式错误', 'data': None} + + try: + inviter_profile = YonghuCYaoqing.query.select_related('user').get(yaoqingma=code) + except YonghuCYaoqing.DoesNotExist: + return {'code': 404, 'msg': '邀请码无效', 'data': None} + + inviter = inviter_profile.user + if inviter.UserUID == invitee_user.UserUID: + return {'code': 400, 'msg': '不能绑定自己为邀请人', 'data': None} + + with transaction.atomic(): + invitee_profile = get_or_create_c_yaoqing(invitee_user) + invitee_profile = YonghuCYaoqing.objects.select_for_update().get(pk=invitee_profile.pk) + inviter_profile = YonghuCYaoqing.objects.select_for_update().get(pk=inviter_profile.pk) + + old_inviter_uid = (invitee_profile.yaoqingren or '').strip() or None + new_inviter_uid = inviter.UserUID + + if old_inviter_uid == new_inviter_uid: + shop_info = sync_shop_from_inviter(invitee_user, inviter) + return { + 'code': 200, + 'msg': '已绑定该邀请人', + 'data': { + 'yaoqingren': new_inviter_uid, + 'rebinding': False, + 'shop': shop_info, + }, + } + + # 换绑:旧邀请人计数 -1,新邀请人 +1 + if old_inviter_uid: + old_p = ( + YonghuCYaoqing.objects.select_for_update() + .filter(user__UserUID=old_inviter_uid) + .first() + ) + if old_p and old_p.yaogingshuliang > 0: + old_p.yaogingshuliang = F('yaogingshuliang') - 1 + old_p.save(update_fields=['yaogingshuliang', 'UpdateTime']) + + inviter_profile.yaogingshuliang = F('yaogingshuliang') + 1 + inviter_profile.save(update_fields=['yaogingshuliang', 'UpdateTime']) + + invitee_profile.yaoqingren = new_inviter_uid + invitee_profile.save(update_fields=['yaoqingren', 'UpdateTime']) + + shop_info = sync_shop_from_inviter(invitee_user, inviter) + + logger.info( + 'C端邀请绑定: invitee=%s inviter=%s old=%s shop=%s', + invitee_user.UserUID, new_inviter_uid, old_inviter_uid, shop_info, + ) + return { + 'code': 200, + 'msg': '绑定成功' if not old_inviter_uid else '已换绑邀请人', + 'data': { + 'yaoqingren': new_inviter_uid, + 'rebinding': bool(old_inviter_uid), + 'shop': shop_info, + }, + } diff --git a/users/urls.py b/users/urls.py index 2d8905c..02a5948 100644 --- a/users/urls.py +++ b/users/urls.py @@ -22,6 +22,7 @@ from .tixian_shenhe_views import TixianZddkshApplyView from .paihang_views import PhbHqsjView from .xzj_paihang_views import XzjPhbHqsjView from rank.reward_views import PhbJlXxView, PhbJlWdView, PhbJlLqView, PhbJlPhbView, PhbJlClubListView +from .views.c_invite import CYaoqingmaView, CYaoqingBangdingView, CYaoqingListView, CYaoqingWodeView urlpatterns = [ @@ -38,6 +39,10 @@ urlpatterns = [ path('shangjiagxnc', ShangjiaNichengGengxinView.as_view(), name='商家修改昵称'), path('bossbiaoshi', BossBiaoshiView.as_view(), name='获取老板标识'), path('yaoqingma', YaoqingmaView.as_view(), name='管事生成邀请码'), + path('cyaoqingma', CYaoqingmaView.as_view(), name='点单端生成邀请码'), + path('cyaoqingbd', CYaoqingBangdingView.as_view(), name='点单端绑定邀请人'), + path('cwyqr', CYaoqingListView.as_view(), name='点单端我邀请的人'), + path('cyqwd', CYaoqingWodeView.as_view(), name='点单端我的邀请摘要'), path('dashouzhuce',DashouZhuceView.as_view(), name='打手邀请码注册'), path('gshqyqds', GuanshiYaoqingDashouListView.as_view(), name='管事获取邀请打手'), path('tixianhq', TixianXinxiHuoquView.as_view(), name='获取体现缓存信息'), # 获取提现信息 diff --git a/users/views/c_invite.py b/users/views/c_invite.py new file mode 100644 index 0000000..923c500 --- /dev/null +++ b/users/views/c_invite.py @@ -0,0 +1,168 @@ +"""点单端 C 用户邀请相关接口。""" +import logging + +from django.core.paginator import Paginator, EmptyPage +from django.db.models import Q + +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from users.models import YonghuCYaoqing, UserBoss +from users.business_models import User +from users.services.c_invite import ( + ensure_c_invite_code, + bind_c_inviter_by_code, + get_or_create_c_yaoqing, +) + +logger = logging.getLogger(__name__) + + +class CYaoqingmaView(APIView): + """获取或生成点单端邀请码。POST /yonghu/cyaoqingma""" + permission_classes = [IsAuthenticated] + + def post(self, request): + user = request.user + if not getattr(user, 'UserUID', None): + return Response({'code': 500, 'msg': '用户身份异常', 'data': None}) + try: + profile, code, is_new = ensure_c_invite_code(user) + return Response({ + 'code': 200, + 'msg': '成功生成新邀请码' if is_new else '成功获取现有邀请码', + 'data': { + 'yaoqingma': code, + 'is_new': is_new, + 'yaogingshuliang': profile.yaogingshuliang or 0, + 'yaoqingren': profile.yaoqingren or '', + }, + }) + except Exception as e: + logger.exception('生成点单端邀请码失败: %s', e) + return Response({'code': 500, 'msg': '邀请码生成失败', 'data': None}) + + +class CYaoqingBangdingView(APIView): + """绑定/换绑邀请人(并同步店铺)。POST /yonghu/cyaoqingbd""" + permission_classes = [IsAuthenticated] + + def post(self, request): + invite_code = request.data.get('inviteCode') or request.data.get('yaoqingma') or '' + result = bind_c_inviter_by_code(request.user, invite_code) + return Response({ + 'code': result['code'], + 'msg': result['msg'], + 'data': result.get('data'), + }) + + +class CYaoqingListView(APIView): + """我邀请的人列表。POST /yonghu/cwyqr""" + permission_classes = [IsAuthenticated] + + def post(self, request): + current_user = request.user + try: + page = int(request.data.get('page', 1)) + page_size = int(request.data.get('pageSize', 20)) + except (TypeError, ValueError): + return Response({'code': 400, 'msg': '分页参数错误'}) + if page < 1 or page_size < 1: + return Response({'code': 400, 'msg': '分页参数必须大于0'}) + page_size = min(page_size, 100) + + keyword = (request.data.get('keyword') or '').strip() + + base_qs = YonghuCYaoqing.query.select_related('user').filter( + yaoqingren=current_user.UserUID + ) + + total_all = base_qs.count() + try: + my_profile = current_user.CYaoqingProfile + yaoqingzongshu = my_profile.yaogingshuliang + except YonghuCYaoqing.DoesNotExist: + yaoqingzongshu = total_all + + if keyword: + uid_matched = User.query.filter(UserUID__icontains=keyword).values_list('UserUUID', flat=True) + boss_matched = UserBoss.query.filter(nickname__icontains=keyword).values_list('user_id', flat=True) + base_qs = base_qs.filter(Q(user_id__in=uid_matched) | Q(user_id__in=boss_matched)) + + base_qs = base_qs.order_by('-CreateTime') + paginator = Paginator(base_qs, page_size) + try: + page_obj = paginator.page(page) + has_more = page_obj.has_next() + filtered_total = paginator.count + except EmptyPage: + page_obj = [] + has_more = False + filtered_total = paginator.count + + # 批量取昵称 + user_uuids = [p.user_id for p in page_obj] + nick_map = {} + if user_uuids: + for b in UserBoss.query.filter(user_id__in=user_uuids).only('user_id', 'nickname'): + nick_map[b.user_id] = b.nickname or '' + + rows = [] + for p in page_obj: + u = p.user + rows.append({ + 'uid': u.UserUID, + 'nicheng': nick_map.get(p.user_id) or '微信用户', + 'touxiang': u.Avatar or '', + 'createTime': p.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if p.CreateTime else '', + }) + + return Response({ + 'code': 200, + 'msg': 'ok', + 'data': { + 'list': rows, + 'yaoqingzongshu': yaoqingzongshu, + 'totalCount': filtered_total, + 'currentPage': page, + 'pageSize': page_size, + 'hasMore': has_more, + }, + }) + + +class CYaoqingWodeView(APIView): + """当前用户邀请关系摘要。POST /yonghu/cyqwd""" + permission_classes = [IsAuthenticated] + + def post(self, request): + profile = get_or_create_c_yaoqing(request.user) + inviter_info = None + if profile.yaoqingren: + try: + inviter = User.query.get(UserUID=profile.yaoqingren) + nicheng = '微信用户' + try: + nicheng = inviter.BossProfile.nickname or nicheng + except Exception: + pass + inviter_info = { + 'uid': inviter.UserUID, + 'nicheng': nicheng, + 'touxiang': inviter.Avatar or '', + } + except User.DoesNotExist: + inviter_info = {'uid': profile.yaoqingren, 'nicheng': '未知', 'touxiang': ''} + + return Response({ + 'code': 200, + 'msg': 'ok', + 'data': { + 'yaoqingma': profile.yaoqingma or '', + 'yaogingshuliang': profile.yaogingshuliang or 0, + 'yaoqingren': profile.yaoqingren or '', + 'inviter': inviter_info, + }, + })