From 4b5130c8245d1f904dc40ddfb3b789e8623e08ed Mon Sep 17 00:00:00 2001 From: XingQue Date: Fri, 24 Jul 2026 00:04:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=9A=E5=91=98/=E5=B1=A5=E7=BA=A6?= =?UTF-8?q?=E9=87=91=E9=A1=B5=E6=96=87=E6=A1=88=E6=8C=89=E4=BF=B1=E4=B9=90?= =?UTF-8?q?=E9=83=A8=E5=8F=AF=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 club_recharge_page_copy 表与小程序/后台读写接口,kefu 菜单挂「充值页文案」。 Co-authored-by: Cursor --- .../0015_club_recharge_page_copy.py | 31 +++ jituan/models.py | 18 ++ jituan/services/kefu_menu.py | 2 +- jituan/services/kefu_menu_definitions.py | 2 + jituan/services/recharge_page_copy.py | 204 ++++++++++++++++++ jituan/urls.py | 8 + jituan/views_recharge_page_copy.py | 138 ++++++++++++ 7 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 jituan/migrations/0015_club_recharge_page_copy.py create mode 100644 jituan/services/recharge_page_copy.py create mode 100644 jituan/views_recharge_page_copy.py diff --git a/jituan/migrations/0015_club_recharge_page_copy.py b/jituan/migrations/0015_club_recharge_page_copy.py new file mode 100644 index 0000000..974766c --- /dev/null +++ b/jituan/migrations/0015_club_recharge_page_copy.py @@ -0,0 +1,31 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('jituan', '0014_club_mini_pay_channel_and_ledger'), + ] + + operations = [ + migrations.CreateModel( + name='ClubRechargePageCopy', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部ID')), + ('page_key', models.CharField(db_index=True, max_length=16, verbose_name='huiyuan|yajin')), + ('region_key', models.CharField(db_index=True, max_length=32, verbose_name='rules|benefits|advantages')), + ('title', models.CharField(blank=True, default='', max_length=64, verbose_name='区域标题')), + ('items_json', models.JSONField(blank=True, default=list, verbose_name='条目列表JSON')), + ('enabled', models.BooleanField(default=True, verbose_name='是否启用该区域')), + ('sort_order', models.IntegerField(default=0)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '充值页文案配置', + 'db_table': 'club_recharge_page_copy', + 'unique_together': {('club_id', 'page_key', 'region_key')}, + }, + ), + ] diff --git a/jituan/models.py b/jituan/models.py index 5a346af..842272e 100644 --- a/jituan/models.py +++ b/jituan/models.py @@ -410,6 +410,24 @@ class ClubDashouExamPass(QModel): unique_together = [['club_id', 'yonghuid']] +class ClubRechargePageCopy(QModel): + """会员充值页 / 履约金页按俱乐部可配置的文案与图标。""" + club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部ID') + page_key = models.CharField(max_length=16, db_index=True, verbose_name='huiyuan|yajin') + region_key = models.CharField(max_length=32, db_index=True, verbose_name='rules|benefits|advantages') + title = models.CharField(max_length=64, blank=True, default='', verbose_name='区域标题') + items_json = models.JSONField(default=list, blank=True, verbose_name='条目列表JSON') + enabled = models.BooleanField(default=True, verbose_name='是否启用该区域') + sort_order = models.IntegerField(default=0) + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'club_recharge_page_copy' + unique_together = [['club_id', 'page_key', 'region_key']] + verbose_name = '充值页文案配置' + + class KefuMenuPage(QModel): """客服后台左侧菜单页(固定 page_id,与前端路由对应)。""" page_id = models.CharField(max_length=64, primary_key=True, verbose_name='页面ID') diff --git a/jituan/services/kefu_menu.py b/jituan/services/kefu_menu.py index 29ae044..5864981 100644 --- a/jituan/services/kefu_menu.py +++ b/jituan/services/kefu_menu.py @@ -92,7 +92,7 @@ def _load_menu_pages(): pages = [] need_seed = not pages or not any( - p.page_id in ('miniapp.assets', 'product.fake-orders') for p in pages + p.page_id in ('miniapp.assets', 'product.fake-orders', 'miniapp.recharge-copy') for p in pages ) if need_seed: try: diff --git a/jituan/services/kefu_menu_definitions.py b/jituan/services/kefu_menu_definitions.py index 0932e46..f9d4e5e 100644 --- a/jituan/services/kefu_menu_definitions.py +++ b/jituan/services/kefu_menu_definitions.py @@ -97,6 +97,8 @@ KEFU_MENU_ROW_DEFS = [ 'perm_codes': ['dsks666', '8080a']}, {'page_id': 'miniapp.rank-reward', 'name': '排行榜奖励', 'path': '/miniapp/rank-reward', 'parent_id': 'miniapp', 'sort_order': 118, 'perm_codes': ['phbj666', '8080a']}, + {'page_id': 'miniapp.recharge-copy', 'name': '充值页文案', 'path': '/miniapp/recharge-copy', 'parent_id': 'miniapp', 'sort_order': 119, + 'perm_codes': ['8080a']}, {'page_id': 'shop', 'name': '店铺管理', 'path': '', 'parent_id': '', 'sort_order': 120}, {'page_id': 'shop.list', 'name': '店铺列表', 'path': '/shop/list', 'parent_id': 'shop', 'sort_order': 121, 'perm_codes': ['999a', '999b', '999c', '999d', '999e']}, diff --git a/jituan/services/recharge_page_copy.py b/jituan/services/recharge_page_copy.py new file mode 100644 index 0000000..349586a --- /dev/null +++ b/jituan/services/recharge_page_copy.py @@ -0,0 +1,204 @@ +"""会员/履约金充值页文案按俱乐部读写。""" +import json + +from jituan.models import ClubRechargePageCopy +from jituan.services.club_context import resolve_club_id_from_request, resolve_effective_club_id + +PAGE_HUIYUAN = 'huiyuan' +PAGE_YAJIN = 'yajin' +PAGE_KEYS = {PAGE_HUIYUAN, PAGE_YAJIN} + +REGIONS_BY_PAGE = { + PAGE_HUIYUAN: ('rules', 'benefits', 'advantages'), + PAGE_YAJIN: ('rules', 'benefits'), +} + +REGION_SORT = { + 'rules': 10, + 'benefits': 20, + 'advantages': 30, +} + + +def normalize_page_key(page_key): + key = (page_key or '').strip().lower() + return key if key in PAGE_KEYS else '' + + +def normalize_region_key(page_key, region_key): + key = (region_key or '').strip().lower() + allowed = REGIONS_BY_PAGE.get(page_key) or () + return key if key in allowed else '' + + +def _as_items_list(raw): + if raw is None: + return [] + if isinstance(raw, str): + try: + raw = json.loads(raw) + except (TypeError, ValueError): + return [] + if not isinstance(raw, list): + return [] + out = [] + for item in raw: + if isinstance(item, dict): + out.append(item) + elif isinstance(item, str) and item.strip(): + out.append({'text': item.strip()}) + return out + + +def _normalize_items_for_region(region_key, items): + """按区域清洗条目字段,去掉空条目。""" + cleaned = [] + for item in _as_items_list(items): + if region_key == 'rules': + text = (item.get('text') or '').strip() + if text: + cleaned.append({'text': text}) + elif region_key == 'benefits': + # 会员权益:title/desc;履约金权益:label + title = (item.get('title') or '').strip() + label = (item.get('label') or '').strip() + desc = (item.get('desc') or '').strip() + icon_url = (item.get('icon_url') or '').strip() + accent = (item.get('accent_color') or '').strip() + row = {} + if title: + row['title'] = title + if label: + row['label'] = label + if desc: + row['desc'] = desc + if icon_url: + row['icon_url'] = icon_url + if accent: + row['accent_color'] = accent + if row.get('title') or row.get('label') or row.get('desc'): + cleaned.append(row) + elif region_key == 'advantages': + title = (item.get('title') or '').strip() + desc = (item.get('desc') or '').strip() + icon_url = (item.get('icon_url') or '').strip() + if not (title or desc): + continue + row = {} + if title: + row['title'] = title + if desc: + row['desc'] = desc + if icon_url: + row['icon_url'] = icon_url + cleaned.append(row) + return cleaned + + +def _row_to_region_payload(row, for_admin=False): + items = _as_items_list(row.items_json) + payload = { + 'title': (row.title or '').strip(), + 'items': items, + 'enabled': bool(row.enabled), + 'sort_order': int(row.sort_order or 0), + } + if for_admin: + payload['region_key'] = row.region_key + payload['page_key'] = row.page_key + return payload + + +def get_public_regions(club_id, page_key): + """小程序只读:仅返回 enabled 且 items 非空的区域。""" + page_key = normalize_page_key(page_key) + if not page_key or not club_id: + return {} + rows = ( + ClubRechargePageCopy.query + .filter(club_id=club_id, page_key=page_key, enabled=True) + .order_by('sort_order', 'id') + ) + regions = {} + for row in rows: + items = _as_items_list(row.items_json) + if not items: + continue + regions[row.region_key] = { + 'title': (row.title or '').strip(), + 'items': items, + } + return regions + + +def get_admin_regions(club_id, page_key): + """后台:返回该页全部区域(含未启用/空),缺省区域补空壳。""" + page_key = normalize_page_key(page_key) + if not page_key or not club_id: + return {} + allowed = REGIONS_BY_PAGE[page_key] + existing = { + row.region_key: row + for row in ClubRechargePageCopy.query.filter(club_id=club_id, page_key=page_key) + } + regions = {} + for rk in allowed: + row = existing.get(rk) + if row: + regions[rk] = _row_to_region_payload(row, for_admin=True) + else: + regions[rk] = { + 'region_key': rk, + 'page_key': page_key, + 'title': '', + 'items': [], + 'enabled': False, + 'sort_order': REGION_SORT.get(rk, 0), + } + return regions + + +def save_region(club_id, page_key, region_key, *, title='', items=None, enabled=True, sort_order=None): + page_key = normalize_page_key(page_key) + region_key = normalize_region_key(page_key, region_key) + if not club_id or not page_key or not region_key: + return None, 'page_key 或 region_key 无效' + + items_clean = _normalize_items_for_region(region_key, items) + title = (title or '').strip()[:64] + enabled = bool(enabled) + if sort_order is None: + sort_order = REGION_SORT.get(region_key, 0) + try: + sort_order = int(sort_order) + except (TypeError, ValueError): + sort_order = REGION_SORT.get(region_key, 0) + + row = ClubRechargePageCopy.query.filter( + club_id=club_id, page_key=page_key, region_key=region_key, + ).first() + if row: + row.title = title + row.items_json = items_clean + row.enabled = enabled + row.sort_order = sort_order + row.save(update_fields=['title', 'items_json', 'enabled', 'sort_order', 'UpdateTime']) + else: + row = ClubRechargePageCopy.query.create( + club_id=club_id, + page_key=page_key, + region_key=region_key, + title=title, + items_json=items_clean, + enabled=enabled, + sort_order=sort_order, + ) + return _row_to_region_payload(row, for_admin=True), None + + +def club_id_for_mini(request): + return resolve_club_id_from_request(request) + + +def club_id_for_admin(request, user): + return resolve_effective_club_id(request, user) diff --git a/jituan/urls.py b/jituan/urls.py index 0b0902c..36d5890 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -95,6 +95,11 @@ from jituan.views_identity_tag import ( IdentityTagManageView, IdentityTagMyTagsView, ) +from jituan.views_recharge_page_copy import ( + RechargePageCopyAdminGetView, + RechargePageCopyAdminSaveView, + RechargePageCopyView, +) urlpatterns = [ path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'), @@ -129,6 +134,9 @@ urlpatterns = [ path('houtai/lunbo-list', ClubLunboListView.as_view(), name='jituan_lunbo_list'), path('houtai/lunbo-manage', ClubLunboManageView.as_view(), name='jituan_lunbo_manage'), path('houtai/gonggao', ClubGonggaoView.as_view(), name='jituan_gonggao'), + path('recharge-page-copy', RechargePageCopyView.as_view(), name='jituan_recharge_page_copy'), + path('houtai/recharge-page-copy/get', RechargePageCopyAdminGetView.as_view(), name='jituan_recharge_page_copy_get'), + path('houtai/recharge-page-copy/save', RechargePageCopyAdminSaveView.as_view(), name='jituan_recharge_page_copy_save'), path('identity-tag/my-tags', IdentityTagMyTagsView.as_view(), name='jituan_identity_my_tags'), path('houtai/identity-tag/list', IdentityTagListView.as_view(), name='jituan_identity_tag_list'), path('houtai/identity-tag/manage', IdentityTagManageView.as_view(), name='jituan_identity_tag_manage'), diff --git a/jituan/views_recharge_page_copy.py b/jituan/views_recharge_page_copy.py new file mode 100644 index 0000000..200cd8c --- /dev/null +++ b/jituan/views_recharge_page_copy.py @@ -0,0 +1,138 @@ +"""会员/履约金页文案:小程序只读 + 客服后台读写。""" +from rest_framework.parsers import JSONParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from backend.utils import verify_kefu_permission +from jituan.services.display_config import forbid_display_write_in_all_scope, list_response_meta +from jituan.services.recharge_page_copy import ( + PAGE_KEYS, + club_id_for_admin, + club_id_for_mini, + get_admin_regions, + get_public_regions, + normalize_page_key, + save_region, +) + +POPUP_PERM = '8080a' + + +def _page_key_from_request(request): + if hasattr(request, 'query_params'): + q = request.query_params.get('page_key') + if q: + return q + data = getattr(request, 'data', None) or {} + return data.get('page_key') or '' + + +class RechargePageCopyView(APIView): + """小程序只读 GET/POST /jituan/recharge-page-copy?page_key=huiyuan|yajin""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def get(self, request): + return self._handle(request) + + def post(self, request): + return self._handle(request) + + def _handle(self, request): + page_key = normalize_page_key(_page_key_from_request(request)) + if not page_key: + return Response({'code': 400, 'msg': f'page_key 须为 {", ".join(sorted(PAGE_KEYS))}'}) + club_id = club_id_for_mini(request) + regions = get_public_regions(club_id, page_key) + return Response({ + 'code': 0, + 'data': { + 'page_key': page_key, + 'club_id': club_id, + 'regions': regions, + }, + }) + + +class RechargePageCopyAdminGetView(APIView): + """后台拉全量区域 POST /jituan/houtai/recharge-page-copy/get""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + kefu, permissions = verify_kefu_permission(request, phone) + if kefu is None: + return permissions + if POPUP_PERM not in permissions: + return Response({'code': 403, 'msg': '无权限管理充值页文案'}) + + page_key = normalize_page_key(request.data.get('page_key')) + if not page_key: + return Response({'code': 400, 'msg': f'page_key 须为 {", ".join(sorted(PAGE_KEYS))}'}) + + club_id = club_id_for_admin(request, request.user) + regions = get_admin_regions(club_id, page_key) + return Response({ + 'code': 0, + 'data': { + 'page_key': page_key, + 'club_id': club_id, + 'regions': regions, + **list_response_meta(request), + }, + }) + + +class RechargePageCopyAdminSaveView(APIView): + """后台保存某一区域 POST /jituan/houtai/recharge-page-copy/save""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + phone = (request.data.get('phone') or request.data.get('username') or '').strip() + if not phone: + return Response({'code': 401, 'msg': '缺少客服账号'}) + kefu, permissions = verify_kefu_permission(request, phone) + if kefu is None: + return permissions + if POPUP_PERM not in permissions: + return Response({'code': 403, 'msg': '无权限管理充值页文案'}) + + deny = forbid_display_write_in_all_scope(request) + if deny: + return deny + + page_key = request.data.get('page_key') + region_key = request.data.get('region_key') + title = request.data.get('title') or '' + items = request.data.get('items') + if items is None: + items = request.data.get('items_json') + enabled = request.data.get('enabled', True) + sort_order = request.data.get('sort_order') + + club_id = club_id_for_admin(request, request.user) + data, err = save_region( + club_id, + page_key, + region_key, + title=title, + items=items, + enabled=enabled, + sort_order=sort_order, + ) + if err: + return Response({'code': 400, 'msg': err}) + return Response({ + 'code': 0, + 'msg': '保存成功', + 'data': { + 'page_key': normalize_page_key(page_key), + 'club_id': club_id, + 'region': data, + }, + })