feat: 小程序图标与频道配置 API(按俱乐部)

This commit is contained in:
XingQue
2026-06-26 04:03:52 +08:00
parent 141f17859a
commit 24949b1cb2
8 changed files with 540 additions and 1 deletions

View File

@@ -0,0 +1,67 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('config', '0009_gonggao_copy_pages'),
]
operations = [
migrations.CreateModel(
name='ClubMiniappIcon',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID')),
('icon_key', models.CharField(db_index=True, max_length=64, verbose_name='图标键')),
('label', models.CharField(blank=True, default='', max_length=100, verbose_name='展示名称')),
('image_path', models.CharField(blank=True, default='', max_length=500, verbose_name='图片相对路径')),
('description', models.CharField(blank=True, default='', max_length=300, verbose_name='说明')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '小程序图标',
'verbose_name_plural': '小程序图标',
'db_table': 'club_miniapp_icon',
'ordering': ['icon_key'],
'unique_together': {('club_id', 'icon_key')},
},
),
migrations.CreateModel(
name='ClubPindao',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(default='xq', max_length=16, unique=True, verbose_name='俱乐部ID')),
('channel_no', models.CharField(blank=True, default='', max_length=100, verbose_name='频道号')),
('title', models.CharField(blank=True, default='频道', max_length=100, verbose_name='标题')),
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('UpdateTime', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '俱乐部频道',
'verbose_name_plural': '俱乐部频道',
'db_table': 'club_pindao',
},
),
migrations.CreateModel(
name='ClubPindaoImage',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('club_id', models.CharField(db_index=True, default='xq', max_length=16, verbose_name='俱乐部ID')),
('image_path', models.CharField(max_length=500, verbose_name='图片相对路径')),
('sort_order', models.PositiveIntegerField(default=0, verbose_name='排序')),
('CreateTime', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('pindao', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='images', to='config.clubpindao')),
],
options={
'verbose_name': '频道图片',
'verbose_name_plural': '频道图片',
'db_table': 'club_pindao_image',
'ordering': ['sort_order', 'id'],
},
),
]

View File

@@ -459,3 +459,63 @@ class PopupImage(QModel):
def __str__(self):
return f'{self.popup_config.popup_id} - 图片{self.sort_order}'
class ClubMiniappIcon(QModel):
"""小程序功能图标(按俱乐部,固定 icon_key + 存储桶相对路径)。"""
club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID')
icon_key = models.CharField(max_length=64, db_index=True, verbose_name='图标键')
label = models.CharField(max_length=100, blank=True, default='', verbose_name='展示名称')
image_path = models.CharField(max_length=500, blank=True, default='', verbose_name='图片相对路径')
description = models.CharField(max_length=300, blank=True, default='', verbose_name='说明')
is_active = models.BooleanField(default=True, verbose_name='是否启用')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
db_table = 'club_miniapp_icon'
verbose_name = '小程序图标'
verbose_name_plural = verbose_name
unique_together = [['club_id', 'icon_key']]
ordering = ['icon_key']
def __str__(self):
return f'{self.club_id}:{self.icon_key}'
class ClubPindao(QModel):
"""俱乐部频道配置(每俱乐部一条)。"""
club_id = models.CharField(max_length=16, default='xq', unique=True, verbose_name='俱乐部ID')
channel_no = models.CharField(max_length=100, blank=True, default='', verbose_name='频道号')
title = models.CharField(max_length=100, blank=True, default='频道', verbose_name='标题')
is_active = models.BooleanField(default=True, verbose_name='是否启用')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间')
class Meta:
db_table = 'club_pindao'
verbose_name = '俱乐部频道'
verbose_name_plural = verbose_name
def __str__(self):
return f'{self.club_id} 频道'
class ClubPindaoImage(QModel):
"""频道展示图片(多张)。"""
club_id = models.CharField(max_length=16, default='xq', db_index=True, verbose_name='俱乐部ID')
pindao = models.ForeignKey(
ClubPindao, on_delete=models.CASCADE, related_name='images', null=True, blank=True,
)
image_path = models.CharField(max_length=500, verbose_name='图片相对路径')
sort_order = models.PositiveIntegerField(default=0, verbose_name='排序')
CreateTime = models.DateTimeField(auto_now_add=True, verbose_name='创建时间')
class Meta:
db_table = 'club_pindao_image'
verbose_name = '频道图片'
verbose_name_plural = verbose_name
ordering = ['sort_order', 'id']
def __str__(self):
return f'{self.club_id} 频道图{self.sort_order}'

View File

@@ -67,6 +67,8 @@ class GetDynamicConfigView(APIView):
try:
from jituan.services.display_config import get_tupianpeizhi_url
from jituan.services.miniapp_assets import build_miniapp_assets_payload
cos_oss_url = getattr(settings, 'COS_DOMAIN', '')
if cos_oss_url and not cos_oss_url.endswith('/'):
cos_oss_url += '/'
@@ -102,7 +104,8 @@ class GetDynamicConfigView(APIView):
"kefu": {
"link": kefu_link,
"enterpriseId": kefu_enterprise_id
}
},
"miniappAssets": build_miniapp_assets_payload(request),
}
}
return Response(data)

View File

@@ -182,3 +182,6 @@ def copy_display_config(template_club_id, new_club_id):
text=img.text,
sort_order=img.sort_order,
)
from jituan.services.miniapp_assets import copy_miniapp_assets
copy_miniapp_assets(template_club_id, new_club_id)

View File

@@ -0,0 +1,139 @@
"""小程序频道与可配置图标(按俱乐部)。"""
from jituan.services.display_config import _display_club_id
ICON_FOLDER = 'beijing/tubiao/chengxutubiao'
# icon_key -> 后台展示名(文件名固定为 {icon_key}.png
MINIAPP_ICON_META = [
('merchant_gold_banner', '商家金牌横幅'),
('merchant_regular_dispatch', '常规派单'),
('merchant_custom_dispatch', '自定义派单'),
('merchant_pending_settle', '待清算'),
('merchant_kefu_key', '客服密钥'),
('merchant_kefu_list', '客服列表'),
('merchant_recharge_deposit', '充值保证金'),
('merchant_recharge_member', '充值会员'),
('merchant_link_dispatch', '链接派单'),
('merchant_punish', '处罚记录'),
('merchant_rank', '商家排行'),
('merchant_refresh', '商家刷新'),
('fighter_recharge_member', '打手充值会员'),
('fighter_recharge_deposit', '打手充值保证金'),
('mine_pindao', '频道入口'),
('icon_refresh', '通用刷新'),
]
MINIAPP_ICON_KEYS = [k for k, _ in MINIAPP_ICON_META]
MINIAPP_ICON_LABELS = dict(MINIAPP_ICON_META)
# 无 DB 记录时的默认相对路径(与种子脚本一致)
MINIAPP_ICON_DEFAULTS = {
key: f'{ICON_FOLDER}/{key}.png' for key in MINIAPP_ICON_KEYS
}
# 部分历史资源扩展名不同
MINIAPP_ICON_DEFAULTS['icon_refresh'] = 'beijing/guanshiduan/icon-refresh.png'
MINIAPP_ICON_DEFAULTS['merchant_refresh'] = 'beijing/guanshiduan/icon-refresh.png'
MINIAPP_ICON_DEFAULTS['fighter_recharge_member'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-recharge.jpg'
MINIAPP_ICON_DEFAULTS['fighter_recharge_deposit'] = 'beijing/dashouduan/vip-card-bg.jpg'
MINIAPP_ICON_DEFAULTS['mine_pindao'] = 'beijing/tubiao/grzx_guanzhualong.jpg'
def default_icon_path(icon_key: str) -> str:
return MINIAPP_ICON_DEFAULTS.get(icon_key) or f'{ICON_FOLDER}/{icon_key}.png'
def get_miniapp_icons_dict(club_id: str) -> dict:
from config.models import ClubMiniappIcon
rows = ClubMiniappIcon.query.filter(club_id=club_id, is_active=True)
db_map = {r.icon_key: (r.image_path or '').strip() for r in rows if r.icon_key}
result = {}
for key in MINIAPP_ICON_KEYS:
path = db_map.get(key) or default_icon_path(key)
if path:
result[key] = path
return result
def get_pindao_payload(club_id: str) -> dict:
from config.models import ClubPindao, ClubPindaoImage
row = ClubPindao.query.filter(club_id=club_id, is_active=True).first()
if not row:
return {'channel_no': '', 'title': '频道', 'images': []}
images = [
img.image_path
for img in ClubPindaoImage.query.filter(club_id=club_id).order_by('sort_order', 'id')
if img.image_path
]
return {
'channel_no': row.channel_no or '',
'title': row.title or '频道',
'images': images,
}
def build_miniapp_assets_payload(request) -> dict:
club_id = _display_club_id(request)
return {
'icons': get_miniapp_icons_dict(club_id),
'pindao': get_pindao_payload(club_id),
}
def seed_miniapp_icons_for_club(club_id: str, overwrite: bool = False) -> int:
from config.models import ClubMiniappIcon
created = 0
for key, label in MINIAPP_ICON_META:
path = default_icon_path(key)
row = ClubMiniappIcon.query.filter(club_id=club_id, icon_key=key).first()
if row:
if overwrite:
row.label = label
row.image_path = path
row.is_active = True
row.save(update_fields=['label', 'image_path', 'is_active', 'UpdateTime'])
continue
ClubMiniappIcon.query.create(
club_id=club_id,
icon_key=key,
label=label,
image_path=path,
is_active=True,
)
created += 1
return created
def copy_miniapp_assets(template_club_id: str, new_club_id: str):
from config.models import ClubMiniappIcon, ClubPindao, ClubPindaoImage
for row in ClubMiniappIcon.query.filter(club_id=template_club_id):
if ClubMiniappIcon.query.filter(club_id=new_club_id, icon_key=row.icon_key).exists():
continue
ClubMiniappIcon.query.create(
club_id=new_club_id,
icon_key=row.icon_key,
label=row.label,
image_path=row.image_path,
description=row.description,
is_active=row.is_active,
)
src_pindao = ClubPindao.query.filter(club_id=template_club_id).first()
if not src_pindao or ClubPindao.query.filter(club_id=new_club_id).exists():
return
new_pindao = ClubPindao.query.create(
club_id=new_club_id,
channel_no=src_pindao.channel_no,
title=src_pindao.title,
is_active=src_pindao.is_active,
)
for img in ClubPindaoImage.query.filter(club_id=template_club_id).order_by('sort_order', 'id'):
ClubPindaoImage.query.create(
club_id=new_club_id,
pindao=new_pindao,
image_path=img.image_path,
sort_order=img.sort_order,
)

View File

@@ -26,6 +26,11 @@ from jituan.views_display import (
ClubGonggaoView, ClubLunboListView, ClubLunboManageView,
ClubTupianListView, ClubTupianManageView,
)
from jituan.views_miniapp_assets import (
ClubMiniappIconListView,
ClubMiniappIconManageView,
ClubPindaoManageView,
)
from jituan.views import (
ClubAdminAssignmentListView,
ClubAuditLogListView,
@@ -89,6 +94,9 @@ urlpatterns = [
path('houtai/identity-tag/bind', IdentityTagBindView.as_view(), name='jituan_identity_tag_bind'),
path('houtai/tupian-list', ClubTupianListView.as_view(), name='jituan_tupian_list'),
path('houtai/tupian-manage', ClubTupianManageView.as_view(), name='jituan_tupian_manage'),
path('houtai/miniapp-icon-list', ClubMiniappIconListView.as_view(), name='jituan_miniapp_icon_list'),
path('houtai/miniapp-icon-manage', ClubMiniappIconManageView.as_view(), name='jituan_miniapp_icon_manage'),
path('houtai/pindao-manage', ClubPindaoManageView.as_view(), name='jituan_pindao_manage'),
path('houtai/hthqtcxx', PopupNoticeListAPIView.as_view(), name='jituan_popup_list'),
path('houtai/htxgtcxx', PopupNoticeModifyAPIView.as_view(), name='jituan_popup_modify'),
path('houtai/htglyhqcfsltj', FaKuanTongJiView.as_view(), name='jituan_penalty_stats'),

View File

@@ -0,0 +1,231 @@
"""小程序图标与频道 — 后台管理 API。"""
import os
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
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 config.models import ClubMiniappIcon, ClubPindao, ClubPindaoImage
from jituan.services.club_context import resolve_effective_club_id
from jituan.services.display_config import forbid_display_write_in_all_scope, list_response_meta
from jituan.services.miniapp_assets import (
ICON_FOLDER,
MINIAPP_ICON_LABELS,
MINIAPP_ICON_META,
default_icon_path,
get_miniapp_icons_dict,
get_pindao_payload,
)
from products.views import delete_from_oss, upload_to_oss, validate_image
POPUP_PERM = '8080a'
PINDAO_FOLDER = 'beijing/pindao'
class ClubMiniappIconListView(APIView):
"""POST /jituan/houtai/miniapp-icon-list"""
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': '无权限管理小程序图标'})
club_id = resolve_effective_club_id(request, request.user)
icons_db = {
r.icon_key: r for r in ClubMiniappIcon.query.filter(club_id=club_id)
}
items = []
for key, label in MINIAPP_ICON_META:
row = icons_db.get(key)
items.append({
'icon_key': key,
'label': label,
'image_path': (row.image_path if row else '') or default_icon_path(key),
'description': (row.description if row else '') or '',
'is_active': row.is_active if row else True,
'id': row.id if row else None,
})
return Response({
'code': 0,
'data': {
'icons': items,
'folder': ICON_FOLDER,
**list_response_meta(request),
},
})
class ClubMiniappIconManageView(APIView):
"""POST /jituan/houtai/miniapp-icon-manage action=upload|save_meta"""
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser, 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
action = (request.data.get('action') or 'upload').strip()
club_id = resolve_effective_club_id(request, request.user)
icon_key = (request.data.get('icon_key') or '').strip()
if icon_key not in MINIAPP_ICON_LABELS:
return Response({'code': 400, 'msg': 'icon_key 无效'})
if action == 'save_meta':
description = (request.data.get('description') or '').strip()
row, _ = ClubMiniappIcon.query.get_or_create(
club_id=club_id,
icon_key=icon_key,
defaults={
'label': MINIAPP_ICON_LABELS[icon_key],
'image_path': default_icon_path(icon_key),
},
)
row.description = description
row.label = MINIAPP_ICON_LABELS[icon_key]
row.save(update_fields=['description', 'label', 'UpdateTime'])
return Response({'code': 0, 'msg': '保存成功'})
file_obj = request.FILES.get('file')
if not file_obj:
return Response({'code': 400, 'msg': '请上传图片'})
ok, msg = validate_image(file_obj)
if not ok:
return Response({'code': 400, 'msg': msg})
ext = os.path.splitext(file_obj.name)[1].lower() or '.png'
if ext not in ('.png', '.jpg', '.jpeg', '.webp'):
ext = '.png'
oss_path = f'{ICON_FOLDER}/{icon_key}{ext}'
row = ClubMiniappIcon.query.filter(club_id=club_id, icon_key=icon_key).first()
if row and row.image_path and row.image_path != oss_path:
delete_from_oss(row.image_path)
if not upload_to_oss(file_obj, oss_path):
return Response({'code': 500, 'msg': '图片上传失败'})
if row:
row.image_path = oss_path
row.label = MINIAPP_ICON_LABELS[icon_key]
row.is_active = True
row.save(update_fields=['image_path', 'label', 'is_active', 'UpdateTime'])
else:
ClubMiniappIcon.query.create(
club_id=club_id,
icon_key=icon_key,
label=MINIAPP_ICON_LABELS[icon_key],
image_path=oss_path,
is_active=True,
)
return Response({'code': 0, 'msg': '上传成功', 'data': {'image_path': oss_path}})
class ClubPindaoManageView(APIView):
"""POST /jituan/houtai/pindao-manage"""
permission_classes = [IsAuthenticated]
parser_classes = [MultiPartParser, FormParser, 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': '无权限管理频道'})
action = (request.data.get('action') or 'get').strip()
club_id = resolve_effective_club_id(request, request.user)
if action == 'get':
pindao = get_pindao_payload(club_id)
images = [
{
'id': img.id,
'image_path': img.image_path,
'sort_order': img.sort_order,
}
for img in ClubPindaoImage.query.filter(club_id=club_id).order_by('sort_order', 'id')
]
return Response({
'code': 0,
'data': {
'pindao': pindao,
'images': images,
'folder': PINDAO_FOLDER,
**list_response_meta(request),
},
})
deny = forbid_display_write_in_all_scope(request)
if deny:
return deny
if action == 'save':
channel_no = (request.data.get('channel_no') or '').strip()
title = (request.data.get('title') or '频道').strip() or '频道'
row, _ = ClubPindao.query.get_or_create(club_id=club_id, defaults={'title': title})
row.channel_no = channel_no
row.title = title
row.is_active = True
row.save(update_fields=['channel_no', 'title', 'is_active', 'UpdateTime'])
return Response({'code': 0, 'msg': '保存成功'})
if action == 'add_image':
file_obj = request.FILES.get('file')
if not file_obj:
return Response({'code': 400, 'msg': '请上传图片'})
ok, msg = validate_image(file_obj)
if not ok:
return Response({'code': 400, 'msg': msg})
ext = os.path.splitext(file_obj.name)[1].lower() or '.jpg'
import random
import string
rnd = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
oss_path = f'{PINDAO_FOLDER}/{club_id}_{rnd}{ext}'
if not upload_to_oss(file_obj, oss_path):
return Response({'code': 500, 'msg': '图片上传失败'})
pindao_row, _ = ClubPindao.query.get_or_create(club_id=club_id, defaults={'title': '频道'})
max_sort = ClubPindaoImage.query.filter(club_id=club_id).order_by('-sort_order').first()
sort_order = (max_sort.sort_order + 1) if max_sort else 0
img = ClubPindaoImage.query.create(
club_id=club_id,
pindao=pindao_row,
image_path=oss_path,
sort_order=sort_order,
)
return Response({'code': 0, 'msg': '上传成功', 'data': {'id': img.id, 'image_path': oss_path}})
if action == 'delete_image':
img_id = request.data.get('id')
if not img_id:
return Response({'code': 400, 'msg': '缺少图片 id'})
img = ClubPindaoImage.query.filter(club_id=club_id, id=img_id).first()
if not img:
return Response({'code': 404, 'msg': '图片不存在'})
if img.image_path:
delete_from_oss(img.image_path)
img.delete()
return Response({'code': 0, 'msg': '删除成功'})
return Response({'code': 400, 'msg': 'action 无效'})

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python
"""预置各俱乐部小程序图标配置(仅写 DB 路径,不上传文件)。"""
import os
import sys
import django
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE_DIR)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'a_long_dianjing.settings')
django.setup()
from jituan.models import Club
from jituan.services.miniapp_assets import seed_miniapp_icons_for_club
def main():
clubs = Club.query.filter(status=1).values_list('club_id', flat=True)
club_ids = list(clubs) or ['xq', 'xzj']
total = 0
for cid in club_ids:
n = seed_miniapp_icons_for_club(cid, overwrite=False)
total += n
print(f'俱乐部 {cid}: 新增 {n} 条图标配置')
print(f'完成,共新增 {total}')
if __name__ == '__main__':
main()