Compare commits
122 Commits
2a99e2ea10
...
pre-freeze
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79c86ec373 | ||
|
|
49e07c2673 | ||
|
|
231e5162b0 | ||
|
|
1234cff035 | ||
|
|
7cb41bf4d7 | ||
|
|
6443336bd9 | ||
|
|
98b4526efa | ||
|
|
c870e7e6e9 | ||
|
|
30c2dff040 | ||
|
|
280d8742c9 | ||
|
|
332c42c85d | ||
|
|
ccd0149897 | ||
|
|
0f7e5c503c | ||
|
|
3c017e2ad4 | ||
|
|
fb5a1707db | ||
|
|
ff3d1e2ed3 | ||
|
|
78850a5be8 | ||
|
|
da4514eeda | ||
|
|
59336424e3 | ||
|
|
47939042e6 | ||
|
|
12d01614e9 | ||
|
|
d771add6e8 | ||
|
|
2a2ed25f0c | ||
|
|
e8e98e2970 | ||
|
|
4b5130c824 | ||
|
|
dcb191ac3a | ||
|
|
d2d8e37080 | ||
|
|
0d9139d5cc | ||
|
|
34344fa5b2 | ||
|
|
b41f2b3095 | ||
|
|
de88e55378 | ||
|
|
cddd231dc2 | ||
|
|
46d64fbf92 | ||
|
|
6cb3dff949 | ||
|
|
c25ad40630 | ||
|
|
3313da5ce2 | ||
|
|
f2a9c1abc5 | ||
|
|
44f13e3310 | ||
|
|
d7f40409d6 | ||
|
|
97e9093aa5 | ||
|
|
c37d66745b | ||
|
|
65db2772d8 | ||
|
|
4826a3ca23 | ||
|
|
bc29118a6e | ||
|
|
e722c7016d | ||
|
|
93580a6d63 | ||
|
|
9d49c96e64 | ||
|
|
a483236d45 | ||
|
|
ce86b488d9 | ||
|
|
dc4c0ad091 | ||
|
|
43ad221c67 | ||
|
|
6a107e873d | ||
|
|
dacd55bb1e | ||
|
|
b19430213a | ||
|
|
e03d8728ae | ||
|
|
5762f00d40 | ||
|
|
f36db77f6b | ||
|
|
ab3b9f21b5 | ||
|
|
ef49fe3855 | ||
|
|
24cb0b88f7 | ||
|
|
8e82a8457d | ||
|
|
98b58f8058 | ||
|
|
f1db185d03 | ||
|
|
6515568ec0 | ||
|
|
ed627fa6f7 | ||
|
|
435422e716 | ||
|
|
60d8b52693 | ||
|
|
d5b8ff5b8a | ||
|
|
7c6e14db01 | ||
|
|
27862fa22b | ||
|
|
a432a0b9c4 | ||
|
|
eadf9e2503 | ||
|
|
1ab59efddb | ||
|
|
48d2e13037 | ||
|
|
de74865278 | ||
|
|
e7b59feacd | ||
|
|
b6044cd44a | ||
|
|
cb89720260 | ||
|
|
a1a49261a5 | ||
|
|
52009a54a9 | ||
|
|
60fdf8c427 | ||
|
|
118260a710 | ||
|
|
dac6193d5b | ||
|
|
477129de46 | ||
|
|
9c029288ba | ||
|
|
e6e8e718d3 | ||
|
|
ce8bda52d3 | ||
|
|
80cca803f8 | ||
|
|
5556bbdb61 | ||
|
|
fda4ffb628 | ||
|
|
b02b2dffed | ||
|
|
dc6ddee0d7 | ||
|
|
6689b7af00 | ||
|
|
73f0ce36f2 | ||
|
|
48eca8e5d0 | ||
|
|
6ac32674a5 | ||
|
|
b5021ee07d | ||
|
|
2e419b5b6e | ||
|
|
a28dafa6b7 | ||
|
|
ca67dfa863 | ||
|
|
65a40b5f09 | ||
|
|
d6237ccc7b | ||
|
|
8bc0afb7f4 | ||
|
|
921941a36d | ||
|
|
e1a3c07cef | ||
|
|
49ad57bbb2 | ||
|
|
017d176790 | ||
|
|
5d3e4e32a2 | ||
|
|
f601541eb0 | ||
|
|
df416b8284 | ||
|
|
3660fa4cc7 | ||
|
|
e0fa112da6 | ||
|
|
42963a9ace | ||
| 182f18da91 | |||
|
|
4bda89d7c3 | ||
|
|
5e758a8a46 | ||
| dcdcb7559e | |||
| 5eebbfbff1 | |||
|
|
4d1677a660 | ||
|
|
a3cf7803d8 | ||
| e123510185 | |||
| 4652f3394a |
@@ -42,6 +42,7 @@ INSTALLED_APPS = [
|
|||||||
'rank',
|
'rank',
|
||||||
'merchant_ops',
|
'merchant_ops',
|
||||||
'jituan',
|
'jituan',
|
||||||
|
'gongdan',
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -290,6 +291,12 @@ SHANGJIA_CZ_MIN_AMOUNT = 1
|
|||||||
SHANGJIA_CZ_MAX_AMOUNT = 10000
|
SHANGJIA_CZ_MAX_AMOUNT = 10000
|
||||||
FAKUAN_PAY_DESCRIPTION = '罚款缴纳'
|
FAKUAN_PAY_DESCRIPTION = '罚款缴纳'
|
||||||
FAKUAN_PAY_NOTIFY_URL = 'https://www.abas.asia/hqhd/yonghu/fk_huitiao'
|
FAKUAN_PAY_NOTIFY_URL = 'https://www.abas.asia/hqhd/yonghu/fk_huitiao'
|
||||||
|
ZHUANPAN_PAY_DESCRIPTION = '转盘购票'
|
||||||
|
ZHUANPAN_PAY_NOTIFY_URL = 'https://www.abas.asia/hqhd/shangpin/zhuanpanhuitiao'
|
||||||
|
|
||||||
|
# 支付宝 Czjilu 充值统一回调(会员/押金/积分/商家/考核/罚款)
|
||||||
|
# 与订单 alipay-notify 分离;密钥仍用 app_secrets.ALIPAY_PROD,此处只写死回调地址
|
||||||
|
ALIPAY_CZJILU_NOTIFY_URL = 'https://www.abas.asia/hqhd/shangpin/alipay-czjilu-notify/'
|
||||||
|
|
||||||
# ==================== 订单ID前缀 ====================
|
# ==================== 订单ID前缀 ====================
|
||||||
ORDER_PREFIX = {
|
ORDER_PREFIX = {
|
||||||
@@ -363,6 +370,8 @@ CELERY_TASK_QUEUES = {
|
|||||||
# ==================== 定时任务时间配置 ====================
|
# ==================== 定时任务时间配置 ====================
|
||||||
# 订单超时自动结算(Celery process_expired_order / check_order_expire_task)总开关:False=彻底关闭
|
# 订单超时自动结算(Celery process_expired_order / check_order_expire_task)总开关:False=彻底关闭
|
||||||
ORDER_AUTO_SETTLEMENT_ENABLED = False
|
ORDER_AUTO_SETTLEMENT_ENABLED = False
|
||||||
|
# 打手抢单大厅假单展示:False=仅返回真实订单(后台假单管理仍可用;改 True 可恢复假单池)
|
||||||
|
FAKE_GRAB_ORDER_POOL_ENABLED = False
|
||||||
ORDER_EXPIRE_SECONDS = 48 * 60 * 60 # 48小时 = 172800秒(开关关闭时不生效)
|
ORDER_EXPIRE_SECONDS = 48 * 60 * 60 # 48小时 = 172800秒(开关关闭时不生效)
|
||||||
DAILY_TASK_HOUR = 0
|
DAILY_TASK_HOUR = 0
|
||||||
DAILY_TASK_MINUTE = 30
|
DAILY_TASK_MINUTE = 30
|
||||||
@@ -375,11 +384,11 @@ ORDER_CHECK_INTERVAL = 5
|
|||||||
|
|
||||||
# ==================== 微信支付 V3 ====================
|
# ==================== 微信支付 V3 ====================
|
||||||
WECHAT_PAY_V3_CONFIG = {
|
WECHAT_PAY_V3_CONFIG = {
|
||||||
'MCHID': app_secrets.WECHAT_PAY_V3_MCHID,
|
'MCHID': '1747846572',
|
||||||
'APPID': app_secrets.WECHAT_PAY_V3_APPID,
|
'APPID': 'wx0e4be86faac4a8d1',
|
||||||
'API_V3_KEY': app_secrets.WECHAT_PAY_V3_API_V3_KEY,
|
'API_V3_KEY': 'dujie4637rufjd98ijuyrgcvbvbn4789',
|
||||||
'PRIVATE_KEY_PATH': app_secrets.WECHAT_PAY_V3_PRIVATE_KEY_PATH,
|
'PRIVATE_KEY_PATH': app_secrets.WECHAT_PAY_V3_PRIVATE_KEY_PATH,
|
||||||
'CERT_SERIAL_NO': app_secrets.WECHAT_PAY_V3_CERT_SERIAL_NO,
|
'CERT_SERIAL_NO': '481784CF56E872D9D8122BAC64E6BC7390803582',
|
||||||
'PLATFORM_CERT_DIR': app_secrets.WECHAT_PAY_V3_PLATFORM_CERT_DIR,
|
'PLATFORM_CERT_DIR': app_secrets.WECHAT_PAY_V3_PLATFORM_CERT_DIR,
|
||||||
'NOTIFY_URL': 'https://www.abas.asia/hqhd/yonghu/tixiancallback',
|
'NOTIFY_URL': 'https://www.abas.asia/hqhd/yonghu/tixiancallback',
|
||||||
'TRANSFER_SCENE_ID': '1005',
|
'TRANSFER_SCENE_ID': '1005',
|
||||||
@@ -390,3 +399,6 @@ WECHAT_PAY_V3_CONFIG = {
|
|||||||
ALIPAY_SANDBOX = app_secrets.ALIPAY_SANDBOX
|
ALIPAY_SANDBOX = app_secrets.ALIPAY_SANDBOX
|
||||||
ALIPAY_PROD = app_secrets.ALIPAY_PROD
|
ALIPAY_PROD = app_secrets.ALIPAY_PROD
|
||||||
ALIPAY_SANDBOX_CFG = app_secrets.ALIPAY_SANDBOX_CFG
|
ALIPAY_SANDBOX_CFG = app_secrets.ALIPAY_SANDBOX_CFG
|
||||||
|
# 仅这些 club_id 禁止「发起」支付宝支付;回调/查单仍可用(提现收款不受影响)。
|
||||||
|
# 小溪/小汐 = xx。恢复:改为 frozenset(),再重启 gunicorn。
|
||||||
|
ALIPAY_DISABLED_CLUB_IDS = frozenset({'xx'})
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ urlpatterns = [
|
|||||||
path('shangdian/', include('shop.urls')),
|
path('shangdian/', include('shop.urls')),
|
||||||
path('dengji/', include('rank.urls')),
|
path('dengji/', include('rank.urls')),
|
||||||
path('jituan/', include('jituan.urls')),
|
path('jituan/', include('jituan.urls')),
|
||||||
|
path('gongdan/', include('gongdan.urls')),
|
||||||
]
|
]
|
||||||
|
|
||||||
# 重要说明:
|
# 重要说明:
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
from .view import (GetRolePermissionView,
|
from .view import (GetRolePermissionView,
|
||||||
ModifyRolePermissionView, AddRoleView, GetAdminRolesView, GetAdminUserListView, AddAdminUserView,
|
ModifyRolePermissionView, AddRoleView, GetAdminRolesView, GetAdminUserListView, AddAdminUserView,
|
||||||
ModifyAdminUserView, KefuGetDashouListView, KefuGetDashouDetailView, KefuUpdateDashouView, KefuGetShangjiaListView,
|
ModifyAdminUserView, KefuGetDashouListView, KefuGetDashouDetailView, KefuUpdateDashouView, KefuGetShangjiaListView,
|
||||||
KefuGetShangjiaDetailView, KefuUpdateShangjiaView, GetProductBaseDataView, GetProductListView, SaveProductOrderView,
|
KefuGetShangjiaDetailView, KefuUpdateShangjiaView, KefuAddShangjiaView, GetProductBaseDataView, GetProductListView, SaveProductOrderView,
|
||||||
AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView, UpdateMemberView, GetMemberListView,
|
AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView, UpdateZhiKanShenheView, UpdateMemberView, GetMemberListView,
|
||||||
AddMemberView, GetGuanliListView, GetGuanliDetailView, PromoteGuanshiToZuzhangView, ChangeInviterView, UpdateGuanliView, GetWithdrawSettingsView,
|
AddMemberView, GetGuanliListView, GetGuanliDetailView, PromoteGuanshiToZuzhangView, ChangeInviterView, UpdateGuanliView, GetWithdrawSettingsView,
|
||||||
UpdateWithdrawSettingsView, GetZuzhangListView, GetZuzhangDetailView, UpdateZuzhangView, GetOperationLogListView, GetProductTypeZoneView,
|
UpdateWithdrawSettingsView, GetZuzhangListView, GetZuzhangDetailView, UpdateZuzhangView, GetOperationLogListView, GetProductTypeZoneView,
|
||||||
ModifyProductTypeZoneView, GetRateView, ModifyRateView, PopupNoticeListAPIView, PopupNoticeModifyAPIView,ShopListView,
|
ModifyProductTypeZoneView, GetRateView, ModifyRateView, PopupNoticeListAPIView, PopupNoticeModifyAPIView,ShopListView,
|
||||||
@@ -14,7 +14,8 @@ from .view import (GetRolePermissionView,
|
|||||||
FineApplyView, PunishDashouView,
|
FineApplyView, PunishDashouView,
|
||||||
FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanChuangJianView, FaKuanPingTaiShenHeView,
|
FaKuanTongJiView, FaKuanLieBiaoView, FaKuanChuLiView, FaKuanChuangJianView, FaKuanPingTaiShenHeView,
|
||||||
HqbkxxView,BkxgView,CaiwuView,CwhybkhqView, HybkjtsjView, SzxxView, CwddhqlxView, CwhqjtddsjView,
|
HqbkxxView,BkxgView,CaiwuView,CwhybkhqView, HybkjtsjView, SzxxView, CwddhqlxView, CwhqjtddsjView,
|
||||||
CwqtczhqView, KhpzhqView, ChzsgcView,KhgglView, ShgxgsjView, KhjlglView, KhjlczView, ZxkfghdsView)
|
CwqtczhqView, KhpzhqView, ChzsgcView,KhgglView, ShgxgsjView, KhjlglView, KhjlczView, ZxkfghdsView,
|
||||||
|
DashouGiftHuiyuanSaveView, DashouGiftHuiyuanRemoveView)
|
||||||
from config.views import MiniappScriptHoutaiListView, MiniappScriptHoutaiModifyView
|
from config.views import MiniappScriptHoutaiListView, MiniappScriptHoutaiModifyView
|
||||||
from rank.reward_houtai_views import RankRewardHoutaiListView, RankRewardHoutaiSaveView, RankRewardHoutaiClaimsView
|
from rank.reward_houtai_views import RankRewardHoutaiListView, RankRewardHoutaiSaveView, RankRewardHoutaiClaimsView
|
||||||
|
|
||||||
@@ -30,9 +31,12 @@ urlpatterns = [
|
|||||||
path('kefuhqdslb', KefuGetDashouListView.as_view(), name='客服获取打手数据'),
|
path('kefuhqdslb', KefuGetDashouListView.as_view(), name='客服获取打手数据'),
|
||||||
path('kefuhqdsxq', KefuGetDashouDetailView.as_view(), name='客服获取打手具体信息'),
|
path('kefuhqdsxq', KefuGetDashouDetailView.as_view(), name='客服获取打手具体信息'),
|
||||||
path('kefuxgds', KefuUpdateDashouView.as_view(), name='客服修改打手数据'),
|
path('kefuxgds', KefuUpdateDashouView.as_view(), name='客服修改打手数据'),
|
||||||
|
path('dszsonghy', DashouGiftHuiyuanSaveView.as_view(), name='打手赠送会员'),
|
||||||
|
path('dsycsonghy', DashouGiftHuiyuanRemoveView.as_view(), name='移除打手赠送会员'),
|
||||||
path('hqsjgllb', KefuGetShangjiaListView.as_view(), name='后台获取商家列表'),
|
path('hqsjgllb', KefuGetShangjiaListView.as_view(), name='后台获取商家列表'),
|
||||||
path('hthqsjxq', KefuGetShangjiaDetailView.as_view(), name='后台获取商家详情'),
|
path('hthqsjxq', KefuGetShangjiaDetailView.as_view(), name='后台获取商家详情'),
|
||||||
path('xgsjxx', KefuUpdateShangjiaView.as_view(), name='后台修改商家数据'),
|
path('xgsjxx', KefuUpdateShangjiaView.as_view(), name='后台修改商家数据'),
|
||||||
|
path('tjsj', KefuAddShangjiaView.as_view(), name='后台添加商家'),
|
||||||
|
|
||||||
path('htsphqjcsx', GetProductBaseDataView.as_view(), name='后台获取商品类型专区'),
|
path('htsphqjcsx', GetProductBaseDataView.as_view(), name='后台获取商品类型专区'),
|
||||||
path('hthqsplb', GetProductListView.as_view(), name='后台获取商品数据'),
|
path('hthqsplb', GetProductListView.as_view(), name='后台获取商品数据'),
|
||||||
@@ -41,6 +45,7 @@ urlpatterns = [
|
|||||||
path('htscsp', DeleteProductView.as_view(), name='后台删除商品'),
|
path('htscsp', DeleteProductView.as_view(), name='后台删除商品'),
|
||||||
path('htsphqxq', GetProductDetailView.as_view(), name='后台获取商品详情'),
|
path('htsphqxq', GetProductDetailView.as_view(), name='后台获取商品详情'),
|
||||||
path('bcspxg', UpdateProductView.as_view(), name='后台更新商品数据'),
|
path('bcspxg', UpdateProductView.as_view(), name='后台更新商品数据'),
|
||||||
|
path('htxgzhikan', UpdateZhiKanShenheView.as_view(), name='后台更新只看审核开关'),
|
||||||
|
|
||||||
path('hthqhylb', GetMemberListView.as_view(), name='后台获取会员列表'),
|
path('hthqhylb', GetMemberListView.as_view(), name='后台获取会员列表'),
|
||||||
path('htxghyxx', UpdateMemberView.as_view(), name='后台更新会员信息'),
|
path('htxghyxx', UpdateMemberView.as_view(), name='后台更新会员信息'),
|
||||||
|
|||||||
@@ -35,11 +35,14 @@ from .views.roles import (
|
|||||||
from .views.kefu import (
|
from .views.kefu import (
|
||||||
KefuGetDashouListView, KefuGetDashouDetailView, KefuUpdateDashouView,
|
KefuGetDashouListView, KefuGetDashouDetailView, KefuUpdateDashouView,
|
||||||
KefuGetShangjiaListView, KefuGetShangjiaDetailView, KefuUpdateShangjiaView,
|
KefuGetShangjiaListView, KefuGetShangjiaDetailView, KefuUpdateShangjiaView,
|
||||||
|
KefuAddShangjiaView,
|
||||||
KefuChatPermissionsView,
|
KefuChatPermissionsView,
|
||||||
|
DashouGiftHuiyuanSaveView, DashouGiftHuiyuanRemoveView,
|
||||||
)
|
)
|
||||||
from .views.products import (
|
from .views.products import (
|
||||||
GetProductBaseDataView, GetProductListView, SaveProductOrderView,
|
GetProductBaseDataView, GetProductListView, SaveProductOrderView,
|
||||||
AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView,
|
AddProductView, DeleteProductView, GetProductDetailView, UpdateProductView,
|
||||||
|
UpdateZhiKanShenheView,
|
||||||
)
|
)
|
||||||
from .views.members import (
|
from .views.members import (
|
||||||
GetMemberListView, UpdateMemberView, AddMemberView,
|
GetMemberListView, UpdateMemberView, AddMemberView,
|
||||||
@@ -96,11 +99,14 @@ __all__ = [
|
|||||||
'GetAdminRolesView', 'GetAdminUserListView', 'ModifyAdminUserView', 'AddAdminUserView',
|
'GetAdminRolesView', 'GetAdminUserListView', 'ModifyAdminUserView', 'AddAdminUserView',
|
||||||
# kefu
|
# kefu
|
||||||
'KefuGetDashouListView', 'KefuGetDashouDetailView', 'KefuUpdateDashouView',
|
'KefuGetDashouListView', 'KefuGetDashouDetailView', 'KefuUpdateDashouView',
|
||||||
|
'DashouGiftHuiyuanSaveView', 'DashouGiftHuiyuanRemoveView',
|
||||||
'KefuGetShangjiaListView', 'KefuGetShangjiaDetailView', 'KefuUpdateShangjiaView',
|
'KefuGetShangjiaListView', 'KefuGetShangjiaDetailView', 'KefuUpdateShangjiaView',
|
||||||
|
'KefuAddShangjiaView',
|
||||||
'KefuChatPermissionsView',
|
'KefuChatPermissionsView',
|
||||||
# products
|
# products
|
||||||
'GetProductBaseDataView', 'GetProductListView', 'SaveProductOrderView',
|
'GetProductBaseDataView', 'GetProductListView', 'SaveProductOrderView',
|
||||||
'AddProductView', 'DeleteProductView', 'GetProductDetailView', 'UpdateProductView',
|
'AddProductView', 'DeleteProductView', 'GetProductDetailView', 'UpdateProductView',
|
||||||
|
'UpdateZhiKanShenheView',
|
||||||
# members
|
# members
|
||||||
'GetMemberListView', 'UpdateMemberView', 'AddMemberView',
|
'GetMemberListView', 'UpdateMemberView', 'AddMemberView',
|
||||||
'ClubMemberCatalogView', 'ClubMemberEnableView', 'ClubMemberDisableView',
|
'ClubMemberCatalogView', 'ClubMemberEnableView', 'ClubMemberDisableView',
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ class KefuGetDashouDetailView(APIView):
|
|||||||
'data': {
|
'data': {
|
||||||
'user_info': user_info,
|
'user_info': user_info,
|
||||||
'member_list': member_list,
|
'member_list': member_list,
|
||||||
|
'gift_member_list': [],
|
||||||
'all_members': all_members,
|
'all_members': all_members,
|
||||||
'permissions': permissions,
|
'permissions': permissions,
|
||||||
}
|
}
|
||||||
@@ -369,11 +370,15 @@ class KefuGetDashouDetailView(APIView):
|
|||||||
# 7. 查询所有会员类型(用于前端添加会员)
|
# 7. 查询所有会员类型(用于前端添加会员)
|
||||||
all_members = list(Huiyuan.query.all().values('huiyuan_id', 'jieshao'))
|
all_members = list(Huiyuan.query.all().values('huiyuan_id', 'jieshao'))
|
||||||
|
|
||||||
|
from jituan.services.gift_huiyuan import list_gift_huiyuan_for_admin
|
||||||
|
gift_member_list = list_gift_huiyuan_for_admin(uid)
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'code': 0,
|
'code': 0,
|
||||||
'data': {
|
'data': {
|
||||||
'user_info': user_info,
|
'user_info': user_info,
|
||||||
'member_list': member_list,
|
'member_list': member_list,
|
||||||
|
'gift_member_list': gift_member_list,
|
||||||
'all_members': all_members,
|
'all_members': all_members,
|
||||||
'permissions': permissions, # 返回当前客服的权限列表
|
'permissions': permissions, # 返回当前客服的权限列表
|
||||||
}
|
}
|
||||||
@@ -1105,9 +1110,115 @@ class KefuUpdateShangjiaView(APIView):
|
|||||||
return Response({'code': 400, 'msg': '未知操作'})
|
return Response({'code': 400, 'msg': '未知操作'})
|
||||||
|
|
||||||
|
|
||||||
|
class KefuAddShangjiaView(APIView):
|
||||||
|
"""
|
||||||
|
后台按用户ID开通商家(分俱乐部,禁止前端自助注册场景使用)
|
||||||
|
POST /houtai/tjsj 或 /jituan/houtai/tjsj
|
||||||
|
参数:username, yonghuid(用户业务ID), nicheng(可选)
|
||||||
|
权限:1100a
|
||||||
|
"""
|
||||||
|
permission_classes = []
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
username = request.data.get('username', '').strip()
|
||||||
|
yonghuid = str(request.data.get('yonghuid', '')).strip()
|
||||||
|
nicheng_in = (request.data.get('nicheng') or '').strip()
|
||||||
|
|
||||||
|
if not username:
|
||||||
|
return Response({'code': 401, 'msg': '缺少username'})
|
||||||
|
if not yonghuid:
|
||||||
|
return Response({'code': 400, 'msg': '请填写用户ID'})
|
||||||
|
|
||||||
|
kefu, permissions = verify_kefu_permission(request, username)
|
||||||
|
if kefu is None:
|
||||||
|
return permissions
|
||||||
|
if '1100a' not in permissions:
|
||||||
|
return Response({'code': 403, 'msg': '无权限添加商家(需要1100a)'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.query.get(UserUID=yonghuid)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response({'code': 404, 'msg': '用户不存在,请确认用户ID'})
|
||||||
|
|
||||||
|
denied = forbid_if_user_out_of_scope(request, user)
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
|
if not getattr(user, 'IsActive', True):
|
||||||
|
return Response({'code': 400, 'msg': '该账号已被封禁,无法开通商家'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
dashou = user.DashouProfile
|
||||||
|
if int(getattr(dashou, 'zhanghaozhuangtai', 1) or 0) != 1:
|
||||||
|
return Response({'code': 400, 'msg': '该账号已被封禁,无法开通商家'})
|
||||||
|
except UserDashou.DoesNotExist:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if UserShangjia.query.filter(user=user).exists():
|
||||||
|
return Response({'code': 400, 'msg': '该用户已是商家,无需重复添加'})
|
||||||
|
|
||||||
|
from merchant_ops.services.authz import assert_no_active_staff_when_merchant_register, StaffAuthError
|
||||||
|
try:
|
||||||
|
assert_no_active_staff_when_merchant_register(user)
|
||||||
|
except StaffAuthError as e:
|
||||||
|
return Response({'code': 403, 'msg': getattr(e, 'msg', None) or str(e)})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
boss_nickname = '普通商家'
|
||||||
|
try:
|
||||||
|
boss = user.BossProfile
|
||||||
|
if boss.nickname and boss.nickname.strip():
|
||||||
|
boss_nickname = boss.nickname.strip()
|
||||||
|
except UserBoss.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
shangjia_nicheng = nicheng_in or boss_nickname
|
||||||
|
if UserShangjia.query.filter(nicheng=shangjia_nicheng).exists():
|
||||||
|
shangjia_nicheng = f"{shangjia_nicheng}{random.randint(1000, 9999)}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
shop = UserShangjia.query.create(
|
||||||
|
user=user,
|
||||||
|
nicheng=shangjia_nicheng,
|
||||||
|
zhuangtai=1,
|
||||||
|
yue=0.00,
|
||||||
|
fabu=0,
|
||||||
|
tuikuan=0,
|
||||||
|
chengjiao=0,
|
||||||
|
jinridingdan=0,
|
||||||
|
jinriliushui=0.00,
|
||||||
|
jinyuedingdan=0,
|
||||||
|
jinyueliushui=0.00,
|
||||||
|
dianhua='',
|
||||||
|
wechat='',
|
||||||
|
)
|
||||||
|
write_xiugai_log(
|
||||||
|
yonghuid=yonghuid,
|
||||||
|
xiugaiid=kefu.user.Phone,
|
||||||
|
leixing=XIUGAI_LEIXING_SHANGJIA,
|
||||||
|
qitashuoming=(
|
||||||
|
f'【后台】添加商家:用户ID={yonghuid},昵称={shop.nicheng},'
|
||||||
|
f'操作人={kefu.user.Phone}'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception('后台添加商家失败')
|
||||||
|
return Response({'code': 500, 'msg': f'添加失败:{e}'})
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': '商家添加成功',
|
||||||
|
'data': {
|
||||||
|
'yonghuid': yonghuid,
|
||||||
|
'nicheng': shop.nicheng,
|
||||||
|
'zhuangtai': shop.zhuangtai,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class KefuChatPermissionsView(APIView):
|
class KefuChatPermissionsView(APIView):
|
||||||
@@ -1150,6 +1261,223 @@ class KefuChatPermissionsView(APIView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class DashouGiftHuiyuanSaveView(APIView):
|
||||||
|
"""
|
||||||
|
赠送会员 / 改期(免费接单资格,写入 dashou_zengsong_huiyuan)
|
||||||
|
POST /houtai/dszsonghy
|
||||||
|
Body: {
|
||||||
|
phone, dashou_id, huiyuan_id,
|
||||||
|
daoqi_time: 'YYYY-MM-DD' 或 'YYYY-MM-DD HH:MM:SS',
|
||||||
|
id?: 已有记录ID(改期时建议传)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
from jituan.services.gift_huiyuan import (
|
||||||
|
has_gift_huiyuan_permission,
|
||||||
|
parse_gift_daoqi_time,
|
||||||
|
upsert_gift_huiyuan,
|
||||||
|
update_gift_huiyuan_daoqi,
|
||||||
|
)
|
||||||
|
from products.models import Huiyuan as HuiyuanModel
|
||||||
|
|
||||||
|
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
|
||||||
|
dashou_id = (request.data.get('dashou_id') or request.data.get('uid') or '').strip()
|
||||||
|
huiyuan_id = (request.data.get('huiyuan_id') or '').strip()
|
||||||
|
record_id = request.data.get('id')
|
||||||
|
raw_daoqi = request.data.get('daoqi_time')
|
||||||
|
|
||||||
|
kefu, permissions = verify_kefu_permission(request, phone or None)
|
||||||
|
if kefu is None:
|
||||||
|
return permissions
|
||||||
|
if not has_gift_huiyuan_permission(permissions):
|
||||||
|
return Response({'code': 403, 'msg': '无赠送会员权限(需打手基础管理或加积分权限)'})
|
||||||
|
|
||||||
|
if not dashou_id:
|
||||||
|
return Response({'code': 400, 'msg': '缺少打手ID'})
|
||||||
|
daoqi_time, err = parse_gift_daoqi_time(raw_daoqi)
|
||||||
|
if err:
|
||||||
|
return Response({'code': 400, 'msg': err})
|
||||||
|
|
||||||
|
try:
|
||||||
|
dashou_user = User.query.get(UserUID=dashou_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response({'code': 404, 'msg': '打手不存在'})
|
||||||
|
denied = forbid_if_user_out_of_scope(request, dashou_user)
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
try:
|
||||||
|
dashou_user.DashouProfile
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return Response({'code': 404, 'msg': '该用户不是打手'})
|
||||||
|
|
||||||
|
song_ren = getattr(kefu.user, 'Phone', None) or phone
|
||||||
|
operator = song_ren
|
||||||
|
|
||||||
|
# 有记录ID:只改期
|
||||||
|
if record_id not in (None, ''):
|
||||||
|
try:
|
||||||
|
rid = int(record_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response({'code': 400, 'msg': '无效的记录ID'})
|
||||||
|
rec, err = update_gift_huiyuan_daoqi(
|
||||||
|
record_id=rid,
|
||||||
|
yonghu_id=dashou_id,
|
||||||
|
daoqi_time=daoqi_time,
|
||||||
|
song_ren=song_ren,
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return Response({'code': 400, 'msg': err})
|
||||||
|
try:
|
||||||
|
hy_name = HuiyuanModel.query.get(huiyuan_id=rec.huiyuan_id).jieshao
|
||||||
|
except HuiyuanModel.DoesNotExist:
|
||||||
|
hy_name = rec.huiyuan_id
|
||||||
|
write_xiugai_log(
|
||||||
|
yonghuid=dashou_id,
|
||||||
|
xiugaiid=operator,
|
||||||
|
leixing=XIUGAI_LEIXING_DASHOU,
|
||||||
|
huiyuan_id=rec.huiyuan_id,
|
||||||
|
qitashuoming=(
|
||||||
|
f'【后台】修改赠送会员到期时间:会员ID={rec.huiyuan_id}({hy_name}),'
|
||||||
|
f'新到期={rec.daoqi_time.strftime("%Y-%m-%d %H:%M:%S")},'
|
||||||
|
f'打手ID={dashou_id},操作人={operator}'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': '改期成功',
|
||||||
|
'data': {
|
||||||
|
'id': rec.id,
|
||||||
|
'huiyuan_id': rec.huiyuan_id,
|
||||||
|
'daoqi_time': rec.daoqi_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if not huiyuan_id:
|
||||||
|
return Response({'code': 400, 'msg': '缺少会员ID'})
|
||||||
|
|
||||||
|
rec, created, jifen_info, err = upsert_gift_huiyuan(
|
||||||
|
yonghu_id=dashou_id,
|
||||||
|
huiyuan_id=huiyuan_id,
|
||||||
|
daoqi_time=daoqi_time,
|
||||||
|
song_ren=song_ren,
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return Response({'code': 400, 'msg': err})
|
||||||
|
|
||||||
|
try:
|
||||||
|
hy_name = HuiyuanModel.query.get(huiyuan_id=huiyuan_id).jieshao
|
||||||
|
except HuiyuanModel.DoesNotExist:
|
||||||
|
hy_name = huiyuan_id
|
||||||
|
|
||||||
|
if jifen_info:
|
||||||
|
write_xiugai_log(
|
||||||
|
yonghuid=dashou_id,
|
||||||
|
xiugaiid=operator,
|
||||||
|
leixing=XIUGAI_LEIXING_DASHOU,
|
||||||
|
jifen=jifen_info['after'],
|
||||||
|
yjifen=jifen_info['before'],
|
||||||
|
qitashuoming=(
|
||||||
|
f'【后台】送会员时整改积分:{jifen_info["before"]} → {jifen_info["after"]},'
|
||||||
|
f'原积分={jifen_info["before"]},整改后={jifen_info["after"]},'
|
||||||
|
f'打手ID={dashou_id},操作人={operator}(送会员免费接单)'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
action_label = '赠送' if created else '更新到期时间'
|
||||||
|
write_xiugai_log(
|
||||||
|
yonghuid=dashou_id,
|
||||||
|
xiugaiid=operator,
|
||||||
|
leixing=XIUGAI_LEIXING_DASHOU,
|
||||||
|
huiyuan_id=huiyuan_id,
|
||||||
|
qitashuoming=(
|
||||||
|
f'【后台】{action_label}赠送会员(免费接单):会员ID={huiyuan_id}({hy_name}),'
|
||||||
|
f'到期={rec.daoqi_time.strftime("%Y-%m-%d %H:%M:%S")},'
|
||||||
|
f'打手ID={dashou_id},操作人={operator}(写入赠送会员表,非购买会员)'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': '赠送成功' if created else '到期时间已更新',
|
||||||
|
'data': {
|
||||||
|
'id': rec.id,
|
||||||
|
'huiyuan_id': rec.huiyuan_id,
|
||||||
|
'daoqi_time': rec.daoqi_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'created': created,
|
||||||
|
'jifen_adjusted': bool(jifen_info),
|
||||||
|
'jifen': jifen_info,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class DashouGiftHuiyuanRemoveView(APIView):
|
||||||
|
"""
|
||||||
|
移除赠送会员
|
||||||
|
POST /houtai/dsycsonghy
|
||||||
|
Body: { phone, dashou_id, id? , huiyuan_id? }
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
from jituan.services.gift_huiyuan import (
|
||||||
|
has_gift_huiyuan_permission,
|
||||||
|
remove_gift_huiyuan,
|
||||||
|
)
|
||||||
|
|
||||||
|
phone = (request.data.get('phone') or request.data.get('username') or '').strip()
|
||||||
|
dashou_id = (request.data.get('dashou_id') or request.data.get('uid') or '').strip()
|
||||||
|
record_id = request.data.get('id')
|
||||||
|
huiyuan_id = (request.data.get('huiyuan_id') or '').strip()
|
||||||
|
|
||||||
|
kefu, permissions = verify_kefu_permission(request, phone or None)
|
||||||
|
if kefu is None:
|
||||||
|
return permissions
|
||||||
|
if not has_gift_huiyuan_permission(permissions):
|
||||||
|
return Response({'code': 403, 'msg': '无移除赠送会员权限'})
|
||||||
|
|
||||||
|
if not dashou_id:
|
||||||
|
return Response({'code': 400, 'msg': '缺少打手ID'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
dashou_user = User.query.get(UserUID=dashou_id)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response({'code': 404, 'msg': '打手不存在'})
|
||||||
|
denied = forbid_if_user_out_of_scope(request, dashou_user)
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
|
||||||
|
rid = None
|
||||||
|
if record_id not in (None, ''):
|
||||||
|
try:
|
||||||
|
rid = int(record_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response({'code': 400, 'msg': '无效的记录ID'})
|
||||||
|
|
||||||
|
ok, err = remove_gift_huiyuan(
|
||||||
|
record_id=rid,
|
||||||
|
yonghu_id=dashou_id if not rid else None,
|
||||||
|
huiyuan_id=huiyuan_id if not rid else None,
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return Response({'code': 400, 'msg': err})
|
||||||
|
|
||||||
|
operator = getattr(kefu.user, 'Phone', None) or phone
|
||||||
|
write_xiugai_log(
|
||||||
|
yonghuid=dashou_id,
|
||||||
|
xiugaiid=operator,
|
||||||
|
leixing=XIUGAI_LEIXING_DASHOU,
|
||||||
|
huiyuan_id=huiyuan_id or '',
|
||||||
|
qitashuoming=(
|
||||||
|
f'【后台】移除赠送会员(免费接单):记录ID={rid or "-"},'
|
||||||
|
f'会员ID={huiyuan_id or "-"},打手ID={dashou_id},操作人={operator}'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Response({'code': 0, 'msg': '移除成功'})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -141,62 +141,110 @@ class GetProductBaseDataView(APIView):
|
|||||||
return permissions
|
return permissions
|
||||||
if '2200a' not in permissions:
|
if '2200a' not in permissions:
|
||||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
|
||||||
if scope_err:
|
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
|
||||||
|
|
||||||
# 获取所有商品类型(按 paixu 升序)
|
# 获取所有商品类型(按 paixu 升序);商品数按当前俱乐部统计
|
||||||
type_qs = ShangpinLeixing.query.all().order_by('paixu')
|
from jituan.services.club_context import filter_club_char_field, resolve_club_scope
|
||||||
# 批量聚合商品数量,避免循环内 N+1 count
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
_type_counts = {
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
item['leixing_id']: item['cnt']
|
|
||||||
for item in Shangpin.query.filter(
|
|
||||||
dianpu__isnull=True, dianpu_leixing__isnull=True, leixing_id__isnull=False
|
|
||||||
).values('leixing_id').annotate(cnt=Count('id'))
|
|
||||||
}
|
|
||||||
type_list = []
|
|
||||||
for t in type_qs:
|
|
||||||
product_count = _type_counts.get(t.id, 0)
|
|
||||||
type_list.append({
|
|
||||||
'id': t.id,
|
|
||||||
'jieshao': t.jieshao or '',
|
|
||||||
'tupian_url': t.tupian_url or '',
|
|
||||||
'paixu': t.paixu,
|
|
||||||
'shenhezhuangtai': t.shenhezhuangtai,
|
|
||||||
'product_count': product_count,
|
|
||||||
})
|
|
||||||
|
|
||||||
# 获取所有专区(按 paixu 升序)
|
try:
|
||||||
zone_qs = ShangpinZhuanqu.query.all().order_by('paixu')
|
type_qs = ShangpinLeixing.query.all().order_by('paixu')
|
||||||
_zone_counts = {
|
# 批量聚合商品数量,避免循环内 N+1 count(按俱乐部)
|
||||||
item['zhuanqu_id']: item['cnt']
|
_public_sp = filter_club_char_field(
|
||||||
for item in Shangpin.query.filter(
|
Shangpin.query.filter(
|
||||||
dianpu__isnull=True, dianpu_leixing__isnull=True, zhuanqu_id__isnull=False
|
dianpu__isnull=True, dianpu_leixing__isnull=True, leixing_id__isnull=False
|
||||||
).values('zhuanqu_id').annotate(cnt=Count('id'))
|
),
|
||||||
}
|
request,
|
||||||
zone_list = []
|
field='club_id',
|
||||||
for z in zone_qs:
|
)
|
||||||
product_count = _zone_counts.get(z.id, 0)
|
# 用 Django QS 聚合,避免 Fluent values 链在部分环境下异常
|
||||||
zone_list.append({
|
_type_counts = {
|
||||||
'id': z.id,
|
item['leixing_id']: item['cnt']
|
||||||
'mingzi': z.mingzi or '',
|
for item in _public_sp.values('leixing_id').annotate(cnt=Count('id'))
|
||||||
'leixing_id': z.leixing_id,
|
|
||||||
'paixu': z.paixu,
|
|
||||||
'shenhezhuangtai': z.shenhezhuangtai,
|
|
||||||
'product_count': product_count,
|
|
||||||
})
|
|
||||||
|
|
||||||
# 获取所有会员(只需 ID、介绍、价格)
|
|
||||||
member_list = list(Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage'))
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
'code': 0,
|
|
||||||
'data': {
|
|
||||||
'type_list': type_list,
|
|
||||||
'zone_list': zone_list,
|
|
||||||
'member_list': member_list,
|
|
||||||
}
|
}
|
||||||
})
|
type_list = []
|
||||||
|
for t in type_qs:
|
||||||
|
product_count = _type_counts.get(t.id, 0)
|
||||||
|
type_list.append({
|
||||||
|
'id': t.id,
|
||||||
|
'jieshao': t.jieshao or '',
|
||||||
|
'tupian_url': t.tupian_url or '',
|
||||||
|
'paixu': t.paixu,
|
||||||
|
'shenhezhuangtai': t.shenhezhuangtai,
|
||||||
|
'product_count': product_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
# 获取专区(按俱乐部隔离;集团汇总看全部)
|
||||||
|
zone_qs = filter_club_char_field(
|
||||||
|
ShangpinZhuanqu.query.all().order_by('paixu'), request, field='club_id'
|
||||||
|
)
|
||||||
|
_zone_counts = {
|
||||||
|
item['zhuanqu_id']: item['cnt']
|
||||||
|
for item in filter_club_char_field(
|
||||||
|
Shangpin.query.filter(
|
||||||
|
dianpu__isnull=True, dianpu_leixing__isnull=True, zhuanqu_id__isnull=False
|
||||||
|
),
|
||||||
|
request,
|
||||||
|
field='club_id',
|
||||||
|
).values('zhuanqu_id').annotate(cnt=Count('id'))
|
||||||
|
}
|
||||||
|
zone_list = []
|
||||||
|
for z in zone_qs:
|
||||||
|
product_count = _zone_counts.get(z.id, 0)
|
||||||
|
zone_list.append({
|
||||||
|
'id': z.id,
|
||||||
|
'mingzi': z.mingzi or '',
|
||||||
|
'leixing_id': z.leixing_id,
|
||||||
|
'paixu': z.paixu,
|
||||||
|
'shenhezhuangtai': z.shenhezhuangtai,
|
||||||
|
'product_count': product_count,
|
||||||
|
'club_id': getattr(z, 'club_id', '') or '',
|
||||||
|
'tupian_url': getattr(z, 'tupian_url', '') or '',
|
||||||
|
})
|
||||||
|
|
||||||
|
# 获取所有会员(只需 ID、介绍、价格)
|
||||||
|
member_list = []
|
||||||
|
for m in Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage'):
|
||||||
|
member_list.append({
|
||||||
|
'huiyuan_id': m['huiyuan_id'],
|
||||||
|
'jieshao': m['jieshao'] or '',
|
||||||
|
'jiage': float(m['jiage'] or 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# 本俱乐部「只看审核」开关(集团汇总不下发可写状态)
|
||||||
|
club_scoped = resolve_club_scope(request) != DATA_SCOPE_ALL
|
||||||
|
zhi_kan_shenhe = True
|
||||||
|
club_id = ''
|
||||||
|
if club_scoped:
|
||||||
|
club_id = resolve_club_id_for_write(request) or ''
|
||||||
|
try:
|
||||||
|
from shop.services.shenhe_config import (
|
||||||
|
get_or_create_shenhe_config,
|
||||||
|
is_zhi_kan_shenhe_enabled,
|
||||||
|
)
|
||||||
|
get_or_create_shenhe_config(club_id)
|
||||||
|
zhi_kan_shenhe = is_zhi_kan_shenhe_enabled(club_id)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).exception(
|
||||||
|
'读取只看审核配置失败 club_id=%s(请确认已 migrate shop 0004)',
|
||||||
|
club_id,
|
||||||
|
)
|
||||||
|
zhi_kan_shenhe = True
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'data': {
|
||||||
|
'type_list': type_list,
|
||||||
|
'zone_list': zone_list,
|
||||||
|
'member_list': member_list,
|
||||||
|
'zhi_kan_shenhe': zhi_kan_shenhe,
|
||||||
|
'club_scoped': club_scoped,
|
||||||
|
'club_id': club_id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).exception('GetProductBaseDataView 失败')
|
||||||
|
return Response({'code': 500, 'msg': '获取商品基础数据失败,请稍后重试'})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -235,9 +283,6 @@ class GetProductListView(APIView):
|
|||||||
return permissions
|
return permissions
|
||||||
if '2200a' not in permissions:
|
if '2200a' not in permissions:
|
||||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
|
||||||
if scope_err:
|
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
|
||||||
|
|
||||||
# 获取参数
|
# 获取参数
|
||||||
leixing_id = request.data.get('leixing_id')
|
leixing_id = request.data.get('leixing_id')
|
||||||
@@ -257,11 +302,13 @@ class GetProductListView(APIView):
|
|||||||
except:
|
except:
|
||||||
return Response({'code': 400, 'msg': '分页参数错误'})
|
return Response({'code': 400, 'msg': '分页参数错误'})
|
||||||
|
|
||||||
# 🔥 修改:构建查询集,增加公共商品筛选(店铺为空且店铺商品类型为空)
|
# 🔥 公共商品 + 按俱乐部隔离(集团汇总看全部)
|
||||||
|
from jituan.services.club_context import filter_club_char_field
|
||||||
qs = Shangpin.query.filter(
|
qs = Shangpin.query.filter(
|
||||||
dianpu__isnull=True, # 所属店铺为空
|
dianpu__isnull=True, # 所属店铺为空
|
||||||
dianpu_leixing__isnull=True # 店铺商品类型为空
|
dianpu_leixing__isnull=True # 店铺商品类型为空
|
||||||
)
|
)
|
||||||
|
qs = filter_club_char_field(qs, request, field='club_id')
|
||||||
|
|
||||||
if leixing_id:
|
if leixing_id:
|
||||||
try:
|
try:
|
||||||
@@ -323,8 +370,10 @@ class GetProductListView(APIView):
|
|||||||
'jiage': str(p.jiage),
|
'jiage': str(p.jiage),
|
||||||
'kucun': p.kucun,
|
'kucun': p.kucun,
|
||||||
'zhenshi_xiaoliang': p.zhenshi_xiaoliang,
|
'zhenshi_xiaoliang': p.zhenshi_xiaoliang,
|
||||||
|
'duiwai_xiaoliang': p.duiwai_xiaoliang,
|
||||||
'tupian_url': p.tupian_url or '',
|
'tupian_url': p.tupian_url or '',
|
||||||
'jieshao': p.jieshao or '',
|
'jieshao': p.jieshao or '',
|
||||||
|
'club_id': getattr(p, 'club_id', '') or '',
|
||||||
})
|
})
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
@@ -365,9 +414,6 @@ class GetProductListView(APIView):
|
|||||||
return permissions
|
return permissions
|
||||||
if '2200a' not in permissions:
|
if '2200a' not in permissions:
|
||||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
|
||||||
if scope_err:
|
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
|
||||||
|
|
||||||
# 获取所有商品类型(按 paixu 升序)
|
# 获取所有商品类型(按 paixu 升序)
|
||||||
type_qs = ShangpinLeixing.query.all().order_by('paixu')
|
type_qs = ShangpinLeixing.query.all().order_by('paixu')
|
||||||
@@ -404,6 +450,7 @@ class GetProductListView(APIView):
|
|||||||
'paixu': z.paixu,
|
'paixu': z.paixu,
|
||||||
'shenhezhuangtai': z.shenhezhuangtai,
|
'shenhezhuangtai': z.shenhezhuangtai,
|
||||||
'product_count': product_count,
|
'product_count': product_count,
|
||||||
|
'tupian_url': getattr(z, 'tupian_url', '') or '',
|
||||||
})
|
})
|
||||||
|
|
||||||
# 获取所有会员(只需 ID、介绍、价格)
|
# 获取所有会员(只需 ID、介绍、价格)
|
||||||
@@ -454,9 +501,6 @@ class GetProductListView(APIView):
|
|||||||
return permissions
|
return permissions
|
||||||
if '2200a' not in permissions:
|
if '2200a' not in permissions:
|
||||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
|
||||||
if scope_err:
|
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
|
||||||
|
|
||||||
# 获取参数
|
# 获取参数
|
||||||
leixing_id = request.data.get('leixing_id')
|
leixing_id = request.data.get('leixing_id')
|
||||||
@@ -578,9 +622,6 @@ class SaveProductOrderView(APIView):
|
|||||||
return permissions
|
return permissions
|
||||||
if '2200a' not in permissions:
|
if '2200a' not in permissions:
|
||||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
|
||||||
if scope_err:
|
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
|
||||||
|
|
||||||
sort_type = request.data.get('type', '').strip().lower()
|
sort_type = request.data.get('type', '').strip().lower()
|
||||||
if sort_type not in ('leixing', 'zhuanqu'):
|
if sort_type not in ('leixing', 'zhuanqu'):
|
||||||
@@ -643,9 +684,6 @@ class AddProductView(APIView):
|
|||||||
return permissions
|
return permissions
|
||||||
if '2200a' not in permissions:
|
if '2200a' not in permissions:
|
||||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
|
||||||
if scope_err:
|
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
|
||||||
|
|
||||||
# 获取表单字段
|
# 获取表单字段
|
||||||
biaoqian = request.data.get('biaoqian', '').strip()
|
biaoqian = request.data.get('biaoqian', '').strip()
|
||||||
@@ -658,7 +696,10 @@ class AddProductView(APIView):
|
|||||||
huiyuan_id = request.data.get('huiyuan_id', '').strip()
|
huiyuan_id = request.data.get('huiyuan_id', '').strip()
|
||||||
yongjin_str = request.data.get('yongjin', '0')
|
yongjin_str = request.data.get('yongjin', '0')
|
||||||
fencheng_type = request.data.get('fencheng_type', 'default')
|
fencheng_type = request.data.get('fencheng_type', 'default')
|
||||||
ewai_PlayerCommission_str = request.data.get('ewai_PlayerCommission', '0')
|
# 兼容列表页 ewai_dashou_fencheng 与旧字段 ewai_PlayerCommission
|
||||||
|
ewai_PlayerCommission_str = request.data.get('ewai_dashou_fencheng')
|
||||||
|
if ewai_PlayerCommission_str is None or ewai_PlayerCommission_str == '':
|
||||||
|
ewai_PlayerCommission_str = request.data.get('ewai_PlayerCommission', '0')
|
||||||
jieshao = request.data.get('jieshao', '').strip()
|
jieshao = request.data.get('jieshao', '').strip()
|
||||||
xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').strip()
|
xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').strip()
|
||||||
# 图片文件
|
# 图片文件
|
||||||
@@ -707,6 +748,14 @@ class AddProductView(APIView):
|
|||||||
zhuanqu_obj = ShangpinZhuanqu.query.get(id=zhuanqu_id)
|
zhuanqu_obj = ShangpinZhuanqu.query.get(id=zhuanqu_id)
|
||||||
if zhuanqu_obj.leixing_id != leixing_id:
|
if zhuanqu_obj.leixing_id != leixing_id:
|
||||||
return Response({'code': 400, 'msg': '专区不属于该商品类型'})
|
return Response({'code': 400, 'msg': '专区不属于该商品类型'})
|
||||||
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
|
from jituan.services.club_context import resolve_club_scope
|
||||||
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
|
if resolve_club_scope(request) != DATA_SCOPE_ALL:
|
||||||
|
write_club = resolve_club_id_for_write(request)
|
||||||
|
zone_club = getattr(zhuanqu_obj, 'club_id', None) or 'xq'
|
||||||
|
if zone_club != write_club:
|
||||||
|
return Response({'code': 400, 'msg': '专区不属于当前俱乐部,请先切换俱乐部或选择本俱乐部专区'})
|
||||||
except ShangpinZhuanqu.DoesNotExist:
|
except ShangpinZhuanqu.DoesNotExist:
|
||||||
return Response({'code': 400, 'msg': '商品专区不存在'})
|
return Response({'code': 400, 'msg': '商品专区不存在'})
|
||||||
|
|
||||||
@@ -774,6 +823,8 @@ class AddProductView(APIView):
|
|||||||
|
|
||||||
# 创建商品
|
# 创建商品
|
||||||
try:
|
try:
|
||||||
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
|
club_id = resolve_club_id_for_write(request)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
product = Shangpin.query.create(
|
product = Shangpin.query.create(
|
||||||
biaoqian=biaoqian,
|
biaoqian=biaoqian,
|
||||||
@@ -793,6 +844,7 @@ class AddProductView(APIView):
|
|||||||
ewai_dashou_fencheng=ewai_fencheng,
|
ewai_dashou_fencheng=ewai_fencheng,
|
||||||
paixu=0, # 新商品默认排序最后
|
paixu=0, # 新商品默认排序最后
|
||||||
shenhezhuangtai=1, # 正常状态
|
shenhezhuangtai=1, # 正常状态
|
||||||
|
club_id=club_id,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"创建商品失败: {str(e)}", exc_info=True)
|
logger.error(f"创建商品失败: {str(e)}", exc_info=True)
|
||||||
@@ -843,9 +895,6 @@ class DeleteProductView(APIView):
|
|||||||
return permissions
|
return permissions
|
||||||
if '2200a' not in permissions:
|
if '2200a' not in permissions:
|
||||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
|
||||||
if scope_err:
|
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
|
||||||
|
|
||||||
# 查询商品是否存在
|
# 查询商品是否存在
|
||||||
try:
|
try:
|
||||||
@@ -908,9 +957,6 @@ class GetProductDetailView(APIView):
|
|||||||
return permissions
|
return permissions
|
||||||
if '2200a' not in permissions:
|
if '2200a' not in permissions:
|
||||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
|
||||||
if scope_err:
|
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
|
||||||
|
|
||||||
# 查询商品
|
# 查询商品
|
||||||
try:
|
try:
|
||||||
@@ -946,7 +992,10 @@ class GetProductDetailView(APIView):
|
|||||||
'huiyuan_id': product.huiyuan_id,
|
'huiyuan_id': product.huiyuan_id,
|
||||||
'yongjin': str(product.yongjin) if product.yongjin is not None else None,
|
'yongjin': str(product.yongjin) if product.yongjin is not None else None,
|
||||||
'tupian_url': product.tupian_url,
|
'tupian_url': product.tupian_url,
|
||||||
'kaioi_ewai_PlayerCommission': product.kaioi_ewai_dashou_fencheng,
|
# 兼容两套字段名(客服详情页用 kaioi_ewai_dashou_fencheng)
|
||||||
|
'kaioi_ewai_dashou_fencheng': bool(product.kaioi_ewai_dashou_fencheng),
|
||||||
|
'ewai_dashou_fencheng': str(product.ewai_dashou_fencheng) if product.ewai_dashou_fencheng is not None else '0',
|
||||||
|
'kaioi_ewai_PlayerCommission': bool(product.kaioi_ewai_dashou_fencheng),
|
||||||
'ewai_PlayerCommission': str(product.ewai_dashou_fencheng) if product.ewai_dashou_fencheng is not None else '0',
|
'ewai_PlayerCommission': str(product.ewai_dashou_fencheng) if product.ewai_dashou_fencheng is not None else '0',
|
||||||
'CreateTime': product.CreateTime.isoformat() if product.CreateTime else None,
|
'CreateTime': product.CreateTime.isoformat() if product.CreateTime else None,
|
||||||
'shenhezhuangtai': product.shenhezhuangtai,
|
'shenhezhuangtai': product.shenhezhuangtai,
|
||||||
@@ -992,9 +1041,6 @@ class UpdateProductView(APIView):
|
|||||||
return permissions
|
return permissions
|
||||||
if '2200a' not in permissions:
|
if '2200a' not in permissions:
|
||||||
return Response({'code': 403, 'msg': '无权限管理商品'})
|
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
|
||||||
if scope_err:
|
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
|
||||||
|
|
||||||
# 使用事务,将 select_for_update 放在事务内
|
# 使用事务,将 select_for_update 放在事务内
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -1070,16 +1116,26 @@ class UpdateProductView(APIView):
|
|||||||
except:
|
except:
|
||||||
yongjin = Decimal('0')
|
yongjin = Decimal('0')
|
||||||
|
|
||||||
# ---------- 3. 打手分成 ----------
|
# ---------- 3. 打手分成(兼容详情页字段名与旧 PlayerCommission 别名) ----------
|
||||||
kaioi_ewai = request.data.get('kaioi_ewai_PlayerCommission') == 'true'
|
raw_kaioi = request.data.get('kaioi_ewai_dashou_fencheng')
|
||||||
|
if raw_kaioi is None:
|
||||||
|
raw_kaioi = request.data.get('kaioi_ewai_PlayerCommission')
|
||||||
|
kaioi_ewai = str(raw_kaioi).strip().lower() in ('1', 'true', 'yes', 'on')
|
||||||
|
|
||||||
ewai_fencheng = Decimal('0')
|
ewai_fencheng = Decimal('0')
|
||||||
if kaioi_ewai:
|
if kaioi_ewai:
|
||||||
|
raw_amt = request.data.get('ewai_dashou_fencheng')
|
||||||
|
if raw_amt is None or raw_amt == '':
|
||||||
|
raw_amt = request.data.get('ewai_PlayerCommission', 0)
|
||||||
try:
|
try:
|
||||||
ewai_fencheng = Decimal(str(request.data.get('ewai_PlayerCommission', 0)))
|
ewai_fencheng = Decimal(str(raw_amt))
|
||||||
if ewai_fencheng < 0:
|
if ewai_fencheng < 0:
|
||||||
return Response({'code': 400, 'msg': '分成金额不能为负数'})
|
return Response({'code': 400, 'msg': '分成金额不能为负数'})
|
||||||
except:
|
except Exception:
|
||||||
return Response({'code': 400, 'msg': '分成金额格式错误'})
|
return Response({'code': 400, 'msg': '分成金额格式错误'})
|
||||||
|
else:
|
||||||
|
# 默认分成:清空固定金额,下单按俱乐部利率算
|
||||||
|
ewai_fencheng = None
|
||||||
|
|
||||||
jieshao = request.data.get('jieshao', '').strip()
|
jieshao = request.data.get('jieshao', '').strip()
|
||||||
xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').strip()
|
xiadan_xuzhi = request.data.get('xiadan_xuzhi', '').strip()
|
||||||
@@ -1146,6 +1202,52 @@ class UpdateProductView(APIView):
|
|||||||
return Response({'code': 0, 'msg': '商品修改成功', 'data': {'id': product.id}})
|
return Response({'code': 0, 'msg': '商品修改成功', 'data': {'id': product.id}})
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateZhiKanShenheView(APIView):
|
||||||
|
"""
|
||||||
|
商品管理页:按俱乐部更新「只看审核商品」开关。
|
||||||
|
权限:2200a
|
||||||
|
POST /houtai/htxgzhikan
|
||||||
|
"""
|
||||||
|
permission_classes = []
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
username = request.data.get('username', '').strip()
|
||||||
|
if not username:
|
||||||
|
return Response({'code': 401, 'msg': '缺少username'})
|
||||||
|
|
||||||
|
kefu, permissions = verify_kefu_permission(request, username)
|
||||||
|
if kefu is None:
|
||||||
|
return permissions
|
||||||
|
if '2200a' not in permissions:
|
||||||
|
return Response({'code': 403, 'msg': '无权限管理商品'})
|
||||||
|
|
||||||
|
val = request.data.get('zhi_kan_shenhe')
|
||||||
|
if val is None:
|
||||||
|
return Response({'code': 400, 'msg': '缺少参数 zhi_kan_shenhe'})
|
||||||
|
|
||||||
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
|
from jituan.services.club_context import resolve_club_scope
|
||||||
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
|
from shop.services.shenhe_config import get_or_create_shenhe_config
|
||||||
|
|
||||||
|
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||||
|
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再修改只看审核开关'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
club_id = resolve_club_id_for_write(request)
|
||||||
|
config = get_or_create_shenhe_config(club_id)
|
||||||
|
config = DianpuShangpinShenheShezhi.objects.select_for_update().get(pk=config.pk)
|
||||||
|
config.zhi_kan_shenhe = bool(val)
|
||||||
|
config.save(update_fields=['zhi_kan_shenhe'])
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': '只看审核开关已更新',
|
||||||
|
'data': {'club_id': club_id, 'zhi_kan_shenhe': bool(val)},
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).exception('更新只看审核开关失败')
|
||||||
|
return Response({'code': 500, 'msg': '服务器错误'})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -223,9 +223,15 @@ class ModifyRolePermissionView(APIView):
|
|||||||
if not role_code:
|
if not role_code:
|
||||||
return Response({'code': 400, 'msg': '缺少角色编码'})
|
return Response({'code': 400, 'msg': '缺少角色编码'})
|
||||||
|
|
||||||
|
from jituan.services.admin_context import can_manage_admin_assignments, can_manage_roles_for_club
|
||||||
from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
|
from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
|
||||||
explicit_club = (request.data.get('target_club_id') or request.data.get('club_id') or '').strip() or None
|
explicit_club = (request.data.get('target_club_id') or request.data.get('club_id') or '').strip() or None
|
||||||
bind_club_id = resolve_role_club_id(request, explicit_club)
|
bind_club_id = resolve_role_club_id(request, explicit_club)
|
||||||
|
if bind_club_id:
|
||||||
|
if not can_manage_roles_for_club(request.user, permissions, bind_club_id):
|
||||||
|
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色'})
|
||||||
|
elif not can_manage_admin_assignments(request.user, permissions):
|
||||||
|
return Response({'code': 403, 'msg': '您无权管理全局角色'})
|
||||||
role = resolve_role_by_name(role_code, bind_club_id)
|
role = resolve_role_by_name(role_code, bind_club_id)
|
||||||
if not role:
|
if not role:
|
||||||
return Response({'code': 404, 'msg': '角色不存在或与当前俱乐部不匹配'})
|
return Response({'code': 404, 'msg': '角色不存在或与当前俱乐部不匹配'})
|
||||||
@@ -354,11 +360,17 @@ class AddRoleView(APIView):
|
|||||||
if '000001' not in permissions:
|
if '000001' not in permissions:
|
||||||
return Response({'code': 403, 'msg': '您无权进行此操作'})
|
return Response({'code': 403, 'msg': '您无权进行此操作'})
|
||||||
|
|
||||||
# 检查角色名称是否已存在(同俱乐部内唯一)
|
# 检查角色名称是否已存在(同俱乐部内唯一);单俱乐部视图强制写入当前顶栏俱乐部
|
||||||
|
from jituan.services.admin_context import can_manage_admin_assignments, can_manage_roles_for_club
|
||||||
from jituan.services.club_rbac import resolve_role_club_id, role_name_exists
|
from jituan.services.club_rbac import resolve_role_club_id, role_name_exists
|
||||||
target_club_id = resolve_role_club_id(
|
target_club_id = resolve_role_club_id(
|
||||||
request, (request.data.get('target_club_id') or '').strip() or None,
|
request, (request.data.get('target_club_id') or '').strip() or None,
|
||||||
)
|
)
|
||||||
|
if target_club_id:
|
||||||
|
if not can_manage_roles_for_club(request.user, permissions, target_club_id):
|
||||||
|
return Response({'code': 403, 'msg': '您无权在该俱乐部下创建角色'})
|
||||||
|
elif not can_manage_admin_assignments(request.user, permissions):
|
||||||
|
return Response({'code': 403, 'msg': '仅集团权限可创建全局角色'})
|
||||||
if role_name_exists(role_name, target_club_id):
|
if role_name_exists(role_name, target_club_id):
|
||||||
return Response({'code': 400, 'msg': '该俱乐部下角色名称已存在'})
|
return Response({'code': 400, 'msg': '该俱乐部下角色名称已存在'})
|
||||||
|
|
||||||
@@ -494,16 +506,45 @@ class GetAdminUserListView(APIView):
|
|||||||
'is_primary': a.is_primary,
|
'is_primary': a.is_primary,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
|
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
|
||||||
|
from jituan.services.club_rbac import filter_roles_for_request
|
||||||
|
|
||||||
|
scope = resolve_club_scope(request)
|
||||||
|
curr_club = (resolve_club_id_from_request(request) or '').strip()
|
||||||
|
# 单俱乐部视图:角色下拉/展示只允许「本俱乐部角色」;若本店无角色则仅全局
|
||||||
|
allowed_role_club_ids = None
|
||||||
|
if scope != DATA_SCOPE_ALL:
|
||||||
|
scoped_roles = list(filter_roles_for_request(request).values_list('ClubID', flat=True))
|
||||||
|
allowed_role_club_ids = {(c or '').strip() for c in scoped_roles}
|
||||||
|
|
||||||
|
def _roles_in_view(roles_data):
|
||||||
|
if scope == DATA_SCOPE_ALL or allowed_role_club_ids is None:
|
||||||
|
return roles_data
|
||||||
|
return [
|
||||||
|
r for r in roles_data
|
||||||
|
if (r.get('club_id') or '').strip() in allowed_role_club_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
def _assignments_in_view(assignments):
|
||||||
|
if scope == DATA_SCOPE_ALL:
|
||||||
|
return assignments
|
||||||
|
# 本俱乐部任职 + 集团级(club_id 空);绝不展示其他子公司任职
|
||||||
|
return [
|
||||||
|
a for a in assignments
|
||||||
|
if not a.get('club_id') or a.get('club_id') == curr_club
|
||||||
|
]
|
||||||
|
|
||||||
data_list = []
|
data_list = []
|
||||||
for user in _page_users:
|
for user in _page_users:
|
||||||
roles_data = _roles_by_user.get(user.UserUUID, [])
|
roles_data = _roles_in_view(_roles_by_user.get(user.UserUUID, []))
|
||||||
data_list.append({
|
data_list.append({
|
||||||
'phone': user.Phone or '',
|
'phone': user.Phone or '',
|
||||||
'yonghuid': user.UserUID or '',
|
'yonghuid': user.UserUID or '',
|
||||||
'nicheng': user.KefuProfile.nicheng,
|
'nicheng': user.KefuProfile.nicheng,
|
||||||
'club_id': getattr(user, 'ClubID', '') or '',
|
'club_id': getattr(user, 'ClubID', '') or '',
|
||||||
'roles': roles_data,
|
'roles': roles_data,
|
||||||
'assignments': _assignments_by_user.get(user.UserUID, []),
|
'assignments': _assignments_in_view(_assignments_by_user.get(user.UserUID, [])),
|
||||||
'status': user.KefuProfile.zhuangtai,
|
'status': user.KefuProfile.zhuangtai,
|
||||||
'create_time': user.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user, 'UserCreateTime', None) else '',
|
'create_time': user.UserCreateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user, 'UserCreateTime', None) else '',
|
||||||
'update_time': user.KefuProfile.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user.KefuProfile, 'UpdateTime', None) else '',
|
'update_time': user.KefuProfile.UpdateTime.strftime('%Y-%m-%d %H:%M:%S') if getattr(user.KefuProfile, 'UpdateTime', None) else '',
|
||||||
@@ -512,6 +553,15 @@ class GetAdminUserListView(APIView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
from jituan.services.admin_context import can_manage_admin_assignments, get_manageable_club_ids
|
from jituan.services.admin_context import can_manage_admin_assignments, get_manageable_club_ids
|
||||||
|
manageable = sorted(get_manageable_club_ids(
|
||||||
|
request.user, permissions, current_club_id=curr_club,
|
||||||
|
))
|
||||||
|
if scope != DATA_SCOPE_ALL:
|
||||||
|
# 顶栏在具体俱乐部时,可管理集合收敛到当前店(含超级管理 000001)
|
||||||
|
manageable = [c for c in manageable if c == curr_club]
|
||||||
|
# 兜底:本店超级管理一定能管本店用户(不依赖集团任职)
|
||||||
|
if not manageable and curr_club and permissions and '000001' in permissions:
|
||||||
|
manageable = [curr_club]
|
||||||
return Response({
|
return Response({
|
||||||
'code': 0,
|
'code': 0,
|
||||||
'data': {
|
'data': {
|
||||||
@@ -520,7 +570,12 @@ class GetAdminUserListView(APIView):
|
|||||||
'page': page,
|
'page': page,
|
||||||
'pageSize': page_size,
|
'pageSize': page_size,
|
||||||
'can_manage_club_assignments': can_manage_admin_assignments(request.user, permissions),
|
'can_manage_club_assignments': can_manage_admin_assignments(request.user, permissions),
|
||||||
'manageable_club_ids': sorted(get_manageable_club_ids(request.user, permissions)),
|
'can_manage_club_users': bool(manageable) or (
|
||||||
|
bool(permissions) and '000001' in permissions and scope != DATA_SCOPE_ALL
|
||||||
|
),
|
||||||
|
'manageable_club_ids': manageable,
|
||||||
|
'current_club_id': curr_club,
|
||||||
|
'scope': scope,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -546,19 +601,23 @@ class GetAdminRolesView(APIView):
|
|||||||
if not can_access_admin_user_management(request.user, permissions):
|
if not can_access_admin_user_management(request.user, permissions):
|
||||||
return Response({'code': 403, 'msg': '您无权访问此页面'})
|
return Response({'code': 403, 'msg': '您无权访问此页面'})
|
||||||
|
|
||||||
from jituan.services.admin_context import can_manage_admin_assignments, get_manageable_club_ids
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
|
from jituan.services.admin_context import can_manage_admin_assignments
|
||||||
|
from jituan.services.club_context import resolve_club_scope
|
||||||
from jituan.services.club_rbac import filter_roles_for_request
|
from jituan.services.club_rbac import filter_roles_for_request
|
||||||
|
|
||||||
|
scope = resolve_club_scope(request)
|
||||||
all_clubs = request.data.get('all_clubs') in (True, 1, '1', 'true', 'yes')
|
all_clubs = request.data.get('all_clubs') in (True, 1, '1', 'true', 'yes')
|
||||||
if all_clubs and can_manage_admin_assignments(request.user, permissions):
|
# 仅集团汇总视图 + 集团任职管理权 才可拉全量子公司角色;
|
||||||
|
# 顶栏在具体俱乐部时(含集团账号切到某店),绝不容许其他俱乐部角色泄露。
|
||||||
|
if (
|
||||||
|
scope == DATA_SCOPE_ALL
|
||||||
|
and all_clubs
|
||||||
|
and can_manage_admin_assignments(request.user, permissions)
|
||||||
|
):
|
||||||
roles = Role.objects.filter(RoleStatus=1).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
|
roles = Role.objects.filter(RoleStatus=1).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
|
||||||
else:
|
else:
|
||||||
manageable = get_manageable_club_ids(request.user, permissions)
|
roles = filter_roles_for_request(request).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
|
||||||
if manageable:
|
|
||||||
roles = Role.objects.filter(
|
|
||||||
RoleStatus=1, ClubID__in=manageable,
|
|
||||||
).order_by('ClubID', 'RoleName').values('RoleName', 'ClubID')
|
|
||||||
else:
|
|
||||||
roles = filter_roles_for_request(request).values('RoleName', 'ClubID')
|
|
||||||
return Response({
|
return Response({
|
||||||
'code': 0,
|
'code': 0,
|
||||||
'data': {
|
'data': {
|
||||||
@@ -671,9 +730,11 @@ class ModifyAdminUserView(APIView):
|
|||||||
club_assign_actions = {'add_club_assignment', 'remove_club_assignment'}
|
club_assign_actions = {'add_club_assignment', 'remove_club_assignment'}
|
||||||
role_manage_actions = {'add_user_roles', 'remove_user_role'}
|
role_manage_actions = {'add_user_roles', 'remove_user_role'}
|
||||||
if action in club_assign_actions:
|
if action in club_assign_actions:
|
||||||
|
# 跨店任职仍需集团权限;本店超级管理只管功能角色与账号,不分配其他俱乐部任职
|
||||||
if not can_manage_admin_assignments(request.user, permissions):
|
if not can_manage_admin_assignments(request.user, permissions):
|
||||||
return Response({'code': 403, 'msg': '无权分配俱乐部任职'})
|
return Response({'code': 403, 'msg': '无权分配俱乐部任职(需集团权限)'})
|
||||||
elif action in role_manage_actions:
|
elif action in role_manage_actions:
|
||||||
|
# 本店超级管理 000001 可给本俱乐部用户增删功能角色
|
||||||
if not can_access_admin_user_management(request.user, permissions):
|
if not can_access_admin_user_management(request.user, permissions):
|
||||||
return Response({'code': 403, 'msg': '您无权管理角色分配'})
|
return Response({'code': 403, 'msg': '您无权管理角色分配'})
|
||||||
elif not can_manage_admin_assignments(request.user, permissions) and '000001' not in permissions:
|
elif not can_manage_admin_assignments(request.user, permissions) and '000001' not in permissions:
|
||||||
@@ -721,12 +782,16 @@ class ModifyAdminUserView(APIView):
|
|||||||
role_codes = request.data.get('role_codes', [])
|
role_codes = request.data.get('role_codes', [])
|
||||||
if not role_codes:
|
if not role_codes:
|
||||||
return Response({'code': 400, 'msg': '请选择角色'})
|
return Response({'code': 400, 'msg': '请选择角色'})
|
||||||
from jituan.services.club_rbac import resolve_role_by_name
|
from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
|
||||||
from jituan.services.club_context import resolve_club_id_from_request
|
bind_club_id = resolve_role_club_id(
|
||||||
bind_club_id = (request.data.get('target_club_id') or '').strip()
|
request, (request.data.get('target_club_id') or '').strip() or None,
|
||||||
|
)
|
||||||
if not bind_club_id:
|
if not bind_club_id:
|
||||||
bind_club_id = resolve_club_id_from_request(request)
|
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再分配角色'})
|
||||||
if not can_manage_roles_for_club(request.user, permissions, bind_club_id):
|
if not (
|
||||||
|
can_manage_roles_for_club(request.user, permissions, bind_club_id)
|
||||||
|
or ('000001' in permissions)
|
||||||
|
):
|
||||||
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'})
|
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'})
|
||||||
added, missing = [], []
|
added, missing = [], []
|
||||||
for rc in role_codes:
|
for rc in role_codes:
|
||||||
@@ -766,12 +831,16 @@ class ModifyAdminUserView(APIView):
|
|||||||
role_code = request.data.get('role_code')
|
role_code = request.data.get('role_code')
|
||||||
if not role_code:
|
if not role_code:
|
||||||
return Response({'code': 400, 'msg': '缺少角色编码'})
|
return Response({'code': 400, 'msg': '缺少角色编码'})
|
||||||
from jituan.services.club_rbac import resolve_role_by_name
|
from jituan.services.club_rbac import resolve_role_by_name, resolve_role_club_id
|
||||||
from jituan.services.club_context import resolve_club_id_from_request
|
bind_club_id = resolve_role_club_id(
|
||||||
bind_club_id = (request.data.get('target_club_id') or '').strip()
|
request, (request.data.get('target_club_id') or '').strip() or None,
|
||||||
|
)
|
||||||
if not bind_club_id:
|
if not bind_club_id:
|
||||||
bind_club_id = resolve_club_id_from_request(request)
|
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再操作角色'})
|
||||||
if not can_manage_roles_for_club(request.user, permissions, bind_club_id):
|
if not (
|
||||||
|
can_manage_roles_for_club(request.user, permissions, bind_club_id)
|
||||||
|
or ('000001' in permissions)
|
||||||
|
):
|
||||||
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'})
|
return Response({'code': 403, 'msg': '您无权管理该俱乐部的角色分配'})
|
||||||
role = resolve_role_by_name(role_code, bind_club_id)
|
role = resolve_role_by_name(role_code, bind_club_id)
|
||||||
if not role:
|
if not role:
|
||||||
@@ -839,12 +908,17 @@ class AddAdminUserView(APIView):
|
|||||||
can_manage_admin_assignments,
|
can_manage_admin_assignments,
|
||||||
can_manage_roles_for_club,
|
can_manage_roles_for_club,
|
||||||
)
|
)
|
||||||
from jituan.services.club_context import resolve_club_id_from_request
|
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
|
||||||
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
|
|
||||||
target_club_id = resolve_club_id_from_request(request)
|
target_club_id = resolve_club_id_from_request(request)
|
||||||
|
if resolve_club_scope(request) == DATA_SCOPE_ALL or not target_club_id:
|
||||||
|
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再添加用户'})
|
||||||
|
# 俱乐部超级管理 000001 即可添加本店后台用户,无需集团权限
|
||||||
if not (
|
if not (
|
||||||
can_manage_admin_assignments(request.user, permissions)
|
can_manage_admin_assignments(request.user, permissions)
|
||||||
or can_manage_roles_for_club(request.user, permissions, target_club_id)
|
or can_manage_roles_for_club(request.user, permissions, target_club_id)
|
||||||
|
or ('000001' in permissions)
|
||||||
):
|
):
|
||||||
return Response({'code': 403, 'msg': '您无权在该俱乐部添加用户'})
|
return Response({'code': 403, 'msg': '您无权在该俱乐部添加用户'})
|
||||||
|
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class HqbkxxView(APIView):
|
|||||||
return Response({'code': 403, 'msg': '无板块管理权限'}, status=403)
|
return Response({'code': 403, 'msg': '无板块管理权限'}, status=403)
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
scope_err = block_non_group_catalog_scope(request)
|
||||||
if scope_err:
|
if scope_err:
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
return Response({'code': 400, 'msg': scope_err}, status=400)
|
||||||
|
|
||||||
# 5. 查询所有板块
|
# 5. 查询所有板块
|
||||||
bankuai_list = []
|
bankuai_list = []
|
||||||
@@ -241,7 +241,7 @@ class BkxgView(APIView):
|
|||||||
return Response({'code': 403, 'msg': '无板块管理权限'}, status=403)
|
return Response({'code': 403, 'msg': '无板块管理权限'}, status=403)
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
scope_err = block_non_group_catalog_scope(request)
|
||||||
if scope_err:
|
if scope_err:
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
return Response({'code': 400, 'msg': scope_err}, status=400)
|
||||||
|
|
||||||
# 5. 获取操作类型
|
# 5. 获取操作类型
|
||||||
action = request.data.get('action')
|
action = request.data.get('action')
|
||||||
@@ -279,9 +279,13 @@ class BkxgView(APIView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
# ==================== 以下操作需要 bankuai_id ====================
|
# ==================== 以下操作需要 bankuai_id ====================
|
||||||
bankuai_id = request.data.get('bankuai_id')
|
raw_bankuai_id = request.data.get('bankuai_id')
|
||||||
if not bankuai_id:
|
if raw_bankuai_id is None or str(raw_bankuai_id).strip() == '':
|
||||||
return Response({'code': 1, 'msg': '缺少 bankuai_id'}, status=400)
|
return Response({'code': 1, 'msg': '缺少 bankuai_id'}, status=400)
|
||||||
|
try:
|
||||||
|
bankuai_id = int(raw_bankuai_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response({'code': 1, 'msg': '无效的 bankuai_id'}, status=400)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bk = Bankuai.query.get(bankuai_id=bankuai_id)
|
bk = Bankuai.query.get(bankuai_id=bankuai_id)
|
||||||
|
|||||||
@@ -142,8 +142,10 @@ class ShopListView(APIView):
|
|||||||
zhengqu_zonge_min = request.data.get('zhengqu_zonge_min')
|
zhengqu_zonge_min = request.data.get('zhengqu_zonge_min')
|
||||||
zhengqu_zonge_max = request.data.get('zhengqu_zonge_max')
|
zhengqu_zonge_max = request.data.get('zhengqu_zonge_max')
|
||||||
|
|
||||||
# 4. 构建查询集(先查店铺,预加载关联凭证)
|
# 4. 构建查询集(先查店铺,预加载关联凭证;按俱乐部隔离)
|
||||||
|
from jituan.services.club_context import filter_club_char_field
|
||||||
queryset = Dianpu.query.select_related('pingzheng').all()
|
queryset = Dianpu.query.select_related('pingzheng').all()
|
||||||
|
queryset = filter_club_char_field(queryset, request, field='club_id')
|
||||||
|
|
||||||
if dianpu_mingcheng:
|
if dianpu_mingcheng:
|
||||||
queryset = queryset.filter(dianpu_mingcheng__icontains=dianpu_mingcheng)
|
queryset = queryset.filter(dianpu_mingcheng__icontains=dianpu_mingcheng)
|
||||||
@@ -189,6 +191,7 @@ class ShopListView(APIView):
|
|||||||
'kaiqi_meiri_xiane': dianpu.kaiqi_meiri_xiane,
|
'kaiqi_meiri_xiane': dianpu.kaiqi_meiri_xiane,
|
||||||
'pingzheng__yonghu': pingzheng.yonghu, # 用户ID
|
'pingzheng__yonghu': pingzheng.yonghu, # 用户ID
|
||||||
'pingzheng__zhanghao': pingzheng.zhanghao, # 登录账号
|
'pingzheng__zhanghao': pingzheng.zhanghao, # 登录账号
|
||||||
|
'club_id': getattr(dianpu, 'club_id', '') or '',
|
||||||
})
|
})
|
||||||
|
|
||||||
# 获取默认配置(ID=1,不存在则创建)
|
# 获取默认配置(ID=1,不存在则创建)
|
||||||
@@ -327,6 +330,7 @@ class ShopModifyView(APIView):
|
|||||||
mima=mima,
|
mima=mima,
|
||||||
is_active=True
|
is_active=True
|
||||||
)
|
)
|
||||||
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
dianpu = Dianpu.query.create(
|
dianpu = Dianpu.query.create(
|
||||||
pingzheng=pingzheng,
|
pingzheng=pingzheng,
|
||||||
dianpu_mingcheng=dianpu_mingcheng,
|
dianpu_mingcheng=dianpu_mingcheng,
|
||||||
@@ -336,6 +340,7 @@ class ShopModifyView(APIView):
|
|||||||
shouyi_choucheng_feilv=request.data.get('shouyi_choucheng_feilv', 0),
|
shouyi_choucheng_feilv=request.data.get('shouyi_choucheng_feilv', 0),
|
||||||
kaiqi_meiri_xiane=request.data.get('kaiqi_meiri_xiane', False),
|
kaiqi_meiri_xiane=request.data.get('kaiqi_meiri_xiane', False),
|
||||||
meiri_tixian_xiane=request.data.get('meiri_tixian_xiane', 0),
|
meiri_tixian_xiane=request.data.get('meiri_tixian_xiane', 0),
|
||||||
|
club_id=resolve_club_id_for_write(request),
|
||||||
)
|
)
|
||||||
return Response({'code': 0, 'msg': '店铺创建成功', 'dianpu_id': dianpu.id})
|
return Response({'code': 0, 'msg': '店铺创建成功', 'dianpu_id': dianpu.id})
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
@@ -416,15 +421,38 @@ class ShopPublicTypeAndAuditView(APIView):
|
|||||||
logger.exception("查询公共商品类型失败")
|
logger.exception("查询公共商品类型失败")
|
||||||
return Response({'code': 500, 'msg': '服务器错误,请稍后重试'})
|
return Response({'code': 500, 'msg': '服务器错误,请稍后重试'})
|
||||||
|
|
||||||
# ---------- 获取审核模式配置 ----------
|
# ---------- 获取本俱乐部审核模式配置 ----------
|
||||||
audit_config = DianpuShangpinShenheShezhi.query.filter(id=1).first()
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
kaiqi_shenhe = audit_config.kaiqi_shenhe if audit_config else False
|
from shop.services.shenhe_config import get_or_create_shenhe_config
|
||||||
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
|
from jituan.services.club_context import resolve_club_scope
|
||||||
|
|
||||||
|
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||||
|
# 集团汇总:不自动创建,返回默认值提示前端切到具体俱乐部
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'data': {
|
||||||
|
'public_types': type_list,
|
||||||
|
'kaiqi_shenhe': False,
|
||||||
|
'zhi_kan_shenhe': True,
|
||||||
|
'club_scoped': False,
|
||||||
|
'msg': '请切换到具体俱乐部查看/修改审核展示开关',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
club_id = resolve_club_id_for_write(request)
|
||||||
|
audit_config = get_or_create_shenhe_config(club_id)
|
||||||
|
kaiqi_shenhe = audit_config.kaiqi_shenhe
|
||||||
|
zhi_kan_shenhe = bool(getattr(audit_config, 'zhi_kan_shenhe', True))
|
||||||
|
|
||||||
return Response({
|
return Response({
|
||||||
'code': 0,
|
'code': 0,
|
||||||
'data': {
|
'data': {
|
||||||
'public_types': type_list,
|
'public_types': type_list,
|
||||||
'kaiqi_shenhe': kaiqi_shenhe
|
'kaiqi_shenhe': kaiqi_shenhe,
|
||||||
|
'zhi_kan_shenhe': zhi_kan_shenhe,
|
||||||
|
'club_id': club_id,
|
||||||
|
'club_scoped': True,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -458,8 +486,10 @@ class ShopListForProductView(APIView):
|
|||||||
# 店铺状态筛选(1正常,0封禁,不传则全部)
|
# 店铺状态筛选(1正常,0封禁,不传则全部)
|
||||||
zhuangtai = request.data.get('zhuangtai')
|
zhuangtai = request.data.get('zhuangtai')
|
||||||
|
|
||||||
# ---------- 构建查询集(关联凭证表获取用户ID) ----------
|
# ---------- 构建查询集(关联凭证表获取用户ID;按俱乐部隔离) ----------
|
||||||
|
from jituan.services.club_context import filter_club_char_field
|
||||||
shops = Dianpu.query.select_related('pingzheng').all()
|
shops = Dianpu.query.select_related('pingzheng').all()
|
||||||
|
shops = filter_club_char_field(shops, request, field='club_id')
|
||||||
|
|
||||||
# 状态筛选:只接受 0 或 1
|
# 状态筛选:只接受 0 或 1
|
||||||
if zhuangtai is not None:
|
if zhuangtai is not None:
|
||||||
@@ -615,6 +645,8 @@ class ShopProductModifyView(APIView):
|
|||||||
|
|
||||||
if action == 'update_audit_mode':
|
if action == 'update_audit_mode':
|
||||||
return self._update_audit_mode(request, user_perms)
|
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':
|
elif action == 'update_mapping_status':
|
||||||
return self._update_mapping_status(request, user_perms)
|
return self._update_mapping_status(request, user_perms)
|
||||||
else:
|
else:
|
||||||
@@ -622,22 +654,61 @@ class ShopProductModifyView(APIView):
|
|||||||
|
|
||||||
@transaction.atomic # ✅ 事务修复点
|
@transaction.atomic # ✅ 事务修复点
|
||||||
def _update_audit_mode(self, request, user_perms):
|
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': '无权限修改审核模式'})
|
return Response({'code': 403, 'msg': '无权限修改审核模式'})
|
||||||
|
|
||||||
kaiqi = request.data.get('kaiqi_shenhe')
|
kaiqi = request.data.get('kaiqi_shenhe')
|
||||||
if kaiqi is None:
|
if kaiqi is None:
|
||||||
return Response({'code': 400, 'msg': '缺少参数 kaiqi_shenhe'})
|
return Response({'code': 400, 'msg': '缺少参数 kaiqi_shenhe'})
|
||||||
|
|
||||||
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
|
from jituan.services.club_context import resolve_club_scope
|
||||||
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
|
from shop.services.shenhe_config import get_or_create_shenhe_config
|
||||||
|
|
||||||
|
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||||
|
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再修改审核模式'})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
config, _ = DianpuShangpinShenheShezhi.objects.select_for_update().get_or_create(id=1)
|
club_id = resolve_club_id_for_write(request)
|
||||||
|
config = get_or_create_shenhe_config(club_id)
|
||||||
|
config = DianpuShangpinShenheShezhi.objects.select_for_update().get(pk=config.pk)
|
||||||
config.kaiqi_shenhe = bool(kaiqi)
|
config.kaiqi_shenhe = bool(kaiqi)
|
||||||
config.save(update_fields=['kaiqi_shenhe'])
|
config.save(update_fields=['kaiqi_shenhe'])
|
||||||
return Response({'code': 0, 'msg': '审核模式已更新'})
|
return Response({'code': 0, 'msg': '审核模式已更新', 'data': {'club_id': club_id}})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("更新审核模式失败")
|
logger.exception("更新审核模式失败")
|
||||||
return Response({'code': 500, 'msg': '服务器错误'})
|
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'})
|
||||||
|
|
||||||
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
|
from jituan.services.club_context import resolve_club_scope
|
||||||
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
|
from shop.services.shenhe_config import get_or_create_shenhe_config
|
||||||
|
|
||||||
|
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||||
|
return Response({'code': 400, 'msg': '请先切换到具体俱乐部再修改只看审核开关'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
club_id = resolve_club_id_for_write(request)
|
||||||
|
config = get_or_create_shenhe_config(club_id)
|
||||||
|
config = DianpuShangpinShenheShezhi.objects.select_for_update().get(pk=config.pk)
|
||||||
|
config.zhi_kan_shenhe = bool(val)
|
||||||
|
config.save(update_fields=['zhi_kan_shenhe'])
|
||||||
|
return Response({'code': 0, 'msg': '只看审核开关已更新', 'data': {'club_id': club_id}})
|
||||||
|
except Exception:
|
||||||
|
logger.exception("更新只看审核开关失败")
|
||||||
|
return Response({'code': 500, 'msg': '服务器错误'})
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def _update_mapping_status(self, request, user_perms):
|
def _update_mapping_status(self, request, user_perms):
|
||||||
mapping_id = request.data.get('mapping_id')
|
mapping_id = request.data.get('mapping_id')
|
||||||
|
|||||||
@@ -236,65 +236,85 @@ class GetProductTypeZoneView(APIView):
|
|||||||
return permissions
|
return permissions
|
||||||
if '7007a' not in permissions:
|
if '7007a' not in permissions:
|
||||||
return Response({'code': 403, 'msg': '您无权访问此页面'})
|
return Response({'code': 403, 'msg': '您无权访问此页面'})
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
|
||||||
if scope_err:
|
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
|
||||||
|
|
||||||
# 查询所有商品类型(含专区数量)
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
# 一次性分组聚合所有类型的专区数量,避免循环内 N+1 count
|
from jituan.services.club_context import resolve_club_scope, resolve_club_id_from_request
|
||||||
# 注:ShangpinZhuanqu.leixing_id 是 PositiveIntegerField(非 FK),无法用反向 annotate
|
from jituan.services.club_shangpin_leixing import build_club_leixing_admin_list
|
||||||
zone_count_rows = ShangpinZhuanqu.query.values('leixing_id').annotate(cnt=Count('id'))
|
|
||||||
zone_count_map = {row['leixing_id']: row['cnt'] for row in zone_count_rows}
|
|
||||||
|
|
||||||
types = ShangpinLeixing.query.all().order_by('-paixu') # paixu 越大越靠前
|
club_scoped = resolve_club_scope(request) != DATA_SCOPE_ALL
|
||||||
type_list = []
|
club_id = resolve_club_id_from_request(request)
|
||||||
for t in types:
|
|
||||||
# 从预聚合的 map 中取专区数量
|
try:
|
||||||
zone_count = zone_count_map.get(t.id, 0)
|
zone_qs_for_count = filter_club_char_field(
|
||||||
type_list.append({
|
ShangpinZhuanqu.query.all(), request, field='club_id'
|
||||||
'id': t.id,
|
)
|
||||||
'jieshao': t.jieshao or '',
|
zone_count_map = {
|
||||||
'tupian_url': t.tupian_url or '',
|
row['leixing_id']: row['cnt']
|
||||||
'yaoqiuleixing': t.yaoqiuleixing,
|
for row in zone_qs_for_count.values('leixing_id').annotate(cnt=Count('id'))
|
||||||
'huiyuan_id': t.huiyuan_id or '',
|
}
|
||||||
'yongjin': float(t.yongjin) if t.yongjin is not None else None,
|
|
||||||
'shenhezhuangtai': t.shenhezhuangtai,
|
if club_scoped:
|
||||||
'paixu': t.paixu,
|
# 具体俱乐部:仅本俱乐部已上架类型(与抢单端一致)
|
||||||
'product_count': zone_count,
|
admin_payload = build_club_leixing_admin_list(request)
|
||||||
|
type_list = []
|
||||||
|
for item in (admin_payload.get('list') or []):
|
||||||
|
type_list.append({
|
||||||
|
**item,
|
||||||
|
'product_count': zone_count_map.get(item['id'], 0),
|
||||||
|
'club_enabled': True,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
type_list = []
|
||||||
|
for t in ShangpinLeixing.query.all().order_by('-paixu'):
|
||||||
|
type_list.append({
|
||||||
|
'id': t.id,
|
||||||
|
'jieshao': t.jieshao or '',
|
||||||
|
'tupian_url': t.tupian_url or '',
|
||||||
|
'yaoqiuleixing': t.yaoqiuleixing,
|
||||||
|
'huiyuan_id': t.huiyuan_id or '',
|
||||||
|
'yongjin': float(t.yongjin) if t.yongjin is not None else None,
|
||||||
|
'shenhezhuangtai': t.shenhezhuangtai,
|
||||||
|
'paixu': t.paixu,
|
||||||
|
'product_count': zone_count_map.get(t.id, 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
zones = filter_club_char_field(
|
||||||
|
ShangpinZhuanqu.query.all().order_by('-paixu'), request, field='club_id'
|
||||||
|
)
|
||||||
|
zone_list = [
|
||||||
|
{
|
||||||
|
'id': z.id,
|
||||||
|
'mingzi': z.mingzi or '',
|
||||||
|
'leixing_id': z.leixing_id,
|
||||||
|
'shenhezhuangtai': z.shenhezhuangtai,
|
||||||
|
'paixu': z.paixu,
|
||||||
|
'club_id': getattr(z, 'club_id', '') or '',
|
||||||
|
}
|
||||||
|
for z in zones
|
||||||
|
]
|
||||||
|
|
||||||
|
member_list = [
|
||||||
|
{
|
||||||
|
'huiyuan_id': m['huiyuan_id'],
|
||||||
|
'jieshao': m['jieshao'] or '',
|
||||||
|
'jiage': float(m['jiage'] or 0),
|
||||||
|
}
|
||||||
|
for m in Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage')
|
||||||
|
]
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'data': {
|
||||||
|
'type_list': type_list,
|
||||||
|
'zone_list': zone_list,
|
||||||
|
'member_list': member_list,
|
||||||
|
'club_scoped': club_scoped,
|
||||||
|
'club_id': club_id if club_scoped else '',
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
except Exception:
|
||||||
# 查询所有专区
|
logging.getLogger(__name__).exception('GetProductTypeZoneView 失败')
|
||||||
zones = ShangpinZhuanqu.query.all().order_by('-paixu')
|
return Response({'code': 500, 'msg': '获取类型专区失败,请稍后重试'})
|
||||||
zone_list = [
|
|
||||||
{
|
|
||||||
'id': z.id,
|
|
||||||
'mingzi': z.mingzi or '',
|
|
||||||
'leixing_id': z.leixing_id,
|
|
||||||
'shenhezhuangtai': z.shenhezhuangtai,
|
|
||||||
'paixu': z.paixu,
|
|
||||||
}
|
|
||||||
for z in zones
|
|
||||||
]
|
|
||||||
|
|
||||||
# 查询所有会员(只返回 id、介绍、价格)
|
|
||||||
members = Huiyuan.query.all().values('huiyuan_id', 'jieshao', 'jiage')
|
|
||||||
member_list = [
|
|
||||||
{
|
|
||||||
'huiyuan_id': m['huiyuan_id'],
|
|
||||||
'jieshao': m['jieshao'],
|
|
||||||
'jiage': float(m['jiage']),
|
|
||||||
}
|
|
||||||
for m in members
|
|
||||||
]
|
|
||||||
|
|
||||||
return Response({
|
|
||||||
'code': 0,
|
|
||||||
'data': {
|
|
||||||
'type_list': type_list,
|
|
||||||
'zone_list': zone_list,
|
|
||||||
'member_list': member_list,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -321,11 +341,12 @@ class ModifyProductTypeZoneView(APIView):
|
|||||||
return permissions
|
return permissions
|
||||||
if '7007a' not in permissions:
|
if '7007a' not in permissions:
|
||||||
return Response({'code': 403, 'msg': '您无权进行此操作'})
|
return Response({'code': 403, 'msg': '您无权进行此操作'})
|
||||||
scope_err = block_non_group_catalog_scope(request)
|
|
||||||
if scope_err:
|
|
||||||
return Response({'code': 400, 'msg': scope_err})
|
|
||||||
|
|
||||||
|
# 类型仍须集团汇总;专区允许在具体俱乐部操作
|
||||||
if obj_type == 'leixing':
|
if obj_type == 'leixing':
|
||||||
|
scope_err = block_non_group_catalog_scope(request)
|
||||||
|
if scope_err:
|
||||||
|
return Response({'code': 400, 'msg': scope_err})
|
||||||
return self.handle_leixing(request, action)
|
return self.handle_leixing(request, action)
|
||||||
elif obj_type == 'zhuanqu':
|
elif obj_type == 'zhuanqu':
|
||||||
return self.handle_zhuanqu(request, action)
|
return self.handle_zhuanqu(request, action)
|
||||||
@@ -590,13 +611,17 @@ class ModifyProductTypeZoneView(APIView):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
|
club_id = resolve_club_id_for_write(request)
|
||||||
zhuanqu = ShangpinZhuanqu.query.create(
|
zhuanqu = ShangpinZhuanqu.query.create(
|
||||||
mingzi=mingzi,
|
mingzi=mingzi,
|
||||||
leixing_id=leixing_id,
|
leixing_id=leixing_id,
|
||||||
shenhezhuangtai=shenhezhuangtai,
|
shenhezhuangtai=shenhezhuangtai,
|
||||||
paixu=0
|
paixu=0,
|
||||||
|
club_id=club_id,
|
||||||
|
tupian_url=(request.data.get('tupian_url') or '').strip(),
|
||||||
)
|
)
|
||||||
return Response({'code': 0, 'msg': '添加成功', 'data': {'id': zhuanqu.id}})
|
return Response({'code': 0, 'msg': '添加成功', 'data': {'id': zhuanqu.id, 'club_id': club_id}})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"添加专区失败: {e}", exc_info=True)
|
logger.error(f"添加专区失败: {e}", exc_info=True)
|
||||||
return Response({'code': 500, 'msg': '数据库错误'})
|
return Response({'code': 500, 'msg': '数据库错误'})
|
||||||
@@ -610,6 +635,15 @@ class ModifyProductTypeZoneView(APIView):
|
|||||||
except ShangpinZhuanqu.DoesNotExist:
|
except ShangpinZhuanqu.DoesNotExist:
|
||||||
return Response({'code': 404, 'msg': '专区不存在'})
|
return Response({'code': 404, 'msg': '专区不存在'})
|
||||||
|
|
||||||
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
|
from jituan.services.club_context import resolve_club_scope
|
||||||
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
|
if resolve_club_scope(request) != DATA_SCOPE_ALL:
|
||||||
|
write_club = resolve_club_id_for_write(request)
|
||||||
|
zone_club = getattr(zhuanqu, 'club_id', None) or 'xq'
|
||||||
|
if zone_club != write_club:
|
||||||
|
return Response({'code': 403, 'msg': '专区不属于当前俱乐部'})
|
||||||
|
|
||||||
changes = {}
|
changes = {}
|
||||||
mingzi = request.data.get('mingzi')
|
mingzi = request.data.get('mingzi')
|
||||||
if mingzi is not None and mingzi.strip() != zhuanqu.mingzi:
|
if mingzi is not None and mingzi.strip() != zhuanqu.mingzi:
|
||||||
@@ -637,6 +671,12 @@ class ModifyProductTypeZoneView(APIView):
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return Response({'code': 400, 'msg': '审核状态无效(必须为1或2)'})
|
return Response({'code': 400, 'msg': '审核状态无效(必须为1或2)'})
|
||||||
|
|
||||||
|
if 'tupian_url' in request.data:
|
||||||
|
new_url = (request.data.get('tupian_url') or '').strip()
|
||||||
|
old_url = getattr(zhuanqu, 'tupian_url', None) or ''
|
||||||
|
if new_url != old_url:
|
||||||
|
changes['tupian_url'] = new_url
|
||||||
|
|
||||||
if not changes:
|
if not changes:
|
||||||
return Response({'code': 400, 'msg': '未做任何修改'})
|
return Response({'code': 400, 'msg': '未做任何修改'})
|
||||||
|
|
||||||
@@ -661,6 +701,15 @@ class ModifyProductTypeZoneView(APIView):
|
|||||||
except ShangpinZhuanqu.DoesNotExist:
|
except ShangpinZhuanqu.DoesNotExist:
|
||||||
return Response({'code': 404, 'msg': '专区不存在'})
|
return Response({'code': 404, 'msg': '专区不存在'})
|
||||||
|
|
||||||
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
|
from jituan.services.club_context import resolve_club_scope
|
||||||
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
|
if resolve_club_scope(request) != DATA_SCOPE_ALL:
|
||||||
|
write_club = resolve_club_id_for_write(request)
|
||||||
|
zone_club = getattr(zhuanqu, 'club_id', None) or 'xq'
|
||||||
|
if zone_club != write_club:
|
||||||
|
return Response({'code': 403, 'msg': '专区不属于当前俱乐部'})
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
if delete_products:
|
if delete_products:
|
||||||
Shangpin.query.filter(zhuanqu_id=zone_id).delete()
|
Shangpin.query.filter(zhuanqu_id=zone_id).delete()
|
||||||
|
|||||||
@@ -175,6 +175,58 @@ class KhpzhqView(APIView):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
_CHENGHAO_LEIXING_OK = frozenset({'dashou', 'boss', 'shangjia', 'guanshi', 'zuzhang'})
|
||||||
|
_CHENGHAO_LEIXING_ALIAS = {
|
||||||
|
'打手': 'dashou',
|
||||||
|
'老板': 'boss',
|
||||||
|
'商家': 'shangjia',
|
||||||
|
'管事': 'guanshi',
|
||||||
|
'组长': 'zuzhang',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_chenghao_leixing(raw, default='dashou'):
|
||||||
|
"""兼容 null / 中文显示名 / 首尾空格,统一为合法 leixing 代码。"""
|
||||||
|
if raw is None or raw == '':
|
||||||
|
return default
|
||||||
|
s = str(raw).strip()
|
||||||
|
if not s:
|
||||||
|
return default
|
||||||
|
if s in _CHENGHAO_LEIXING_OK:
|
||||||
|
return s
|
||||||
|
mapped = _CHENGHAO_LEIXING_ALIAS.get(s) or _CHENGHAO_LEIXING_ALIAS.get(s.lower())
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
lower = s.lower()
|
||||||
|
if lower in _CHENGHAO_LEIXING_OK:
|
||||||
|
return lower
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_optional_bankuai_id(raw):
|
||||||
|
"""空值视为未绑定板块;非法/不存在返回 False。"""
|
||||||
|
if raw is None or raw == '':
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
bid = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
if bid <= 0:
|
||||||
|
return None
|
||||||
|
if not Bankuai.query.filter(bankuai_id=bid).exists():
|
||||||
|
return False
|
||||||
|
return bid
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_chenghao_id(raw):
|
||||||
|
if raw is None or str(raw).strip() == '':
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class ChzsgcView(APIView):
|
class ChzsgcView(APIView):
|
||||||
"""
|
"""
|
||||||
称号(标签)增删改查统一接口
|
称号(标签)增删改查统一接口
|
||||||
@@ -197,18 +249,21 @@ class ChzsgcView(APIView):
|
|||||||
|
|
||||||
# ========== 创建称号 ==========
|
# ========== 创建称号 ==========
|
||||||
if action == 'create':
|
if action == 'create':
|
||||||
mingcheng = request.data.get('mingcheng', '').strip()
|
mingcheng = (request.data.get('mingcheng') or '').strip()
|
||||||
leixing = request.data.get('leixing', 'dashou')
|
leixing = _normalize_chenghao_leixing(request.data.get('leixing'), default='dashou')
|
||||||
bankuai_id = request.data.get('bankuai_id')
|
bankuai_id = _parse_optional_bankuai_id(request.data.get('bankuai_id'))
|
||||||
texiao_miaoshu = request.data.get('texiao_miaoshu', '')
|
texiao_miaoshu = request.data.get('texiao_miaoshu', '') or ''
|
||||||
kaohe_guize = request.data.get('kaohe_guize', '')
|
kaohe_guize = request.data.get('kaohe_guize', '') or ''
|
||||||
kaioi_jinpai = request.data.get('kaioi_jinpai', False)
|
kaioi_jinpai = bool(request.data.get('kaioi_jinpai', False))
|
||||||
feiyong_list = request.data.get('feiyong_list', [])
|
feiyong_list = request.data.get('feiyong_list', [])
|
||||||
|
|
||||||
if not mingcheng:
|
if not mingcheng:
|
||||||
return Response({'code': 1, 'msg': '称号名称不能为空'}, status=400)
|
return Response({'code': 1, 'msg': '称号名称不能为空'}, status=400)
|
||||||
if leixing not in ['dashou','boss','shangjia','guanshi','zuzhang']:
|
if leixing is None:
|
||||||
|
logger.warning('ChzsgcView create 无效 leixing=%r', request.data.get('leixing'))
|
||||||
return Response({'code': 1, 'msg': '无效的称号类型'}, status=400)
|
return Response({'code': 1, 'msg': '无效的称号类型'}, status=400)
|
||||||
|
if bankuai_id is False:
|
||||||
|
return Response({'code': 1, 'msg': '板块不存在'}, status=400)
|
||||||
if Chenghao.query.filter(mingcheng=mingcheng).exists():
|
if Chenghao.query.filter(mingcheng=mingcheng).exists():
|
||||||
return Response({'code': 1, 'msg': '称号名称已存在'}, status=400)
|
return Response({'code': 1, 'msg': '称号名称已存在'}, status=400)
|
||||||
|
|
||||||
@@ -238,7 +293,7 @@ class ChzsgcView(APIView):
|
|||||||
|
|
||||||
# ========== 修改称号 ==========
|
# ========== 修改称号 ==========
|
||||||
elif action == 'update':
|
elif action == 'update':
|
||||||
ch_id = request.data.get('chenghao_id')
|
ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
|
||||||
if not ch_id:
|
if not ch_id:
|
||||||
return Response({'code': 1, 'msg': '缺少称号ID'}, status=400)
|
return Response({'code': 1, 'msg': '缺少称号ID'}, status=400)
|
||||||
try:
|
try:
|
||||||
@@ -246,17 +301,25 @@ class ChzsgcView(APIView):
|
|||||||
except Chenghao.DoesNotExist:
|
except Chenghao.DoesNotExist:
|
||||||
return Response({'code': 1, 'msg': '称号不存在'}, status=404)
|
return Response({'code': 1, 'msg': '称号不存在'}, status=404)
|
||||||
|
|
||||||
mingcheng = request.data.get('mingcheng', ch.mingcheng).strip()
|
mingcheng = (request.data.get('mingcheng') if request.data.get('mingcheng') is not None else ch.mingcheng) or ''
|
||||||
leixing = request.data.get('leixing', ch.leixing)
|
mingcheng = str(mingcheng).strip()
|
||||||
bankuai_id = request.data.get('bankuai_id', ch.bankuai_id)
|
raw_leixing = request.data.get('leixing', ch.leixing)
|
||||||
|
leixing = _normalize_chenghao_leixing(raw_leixing, default=ch.leixing or 'dashou')
|
||||||
|
if 'bankuai_id' in request.data:
|
||||||
|
bankuai_id = _parse_optional_bankuai_id(request.data.get('bankuai_id'))
|
||||||
|
else:
|
||||||
|
bankuai_id = ch.bankuai_id
|
||||||
texiao_miaoshu = request.data.get('texiao_miaoshu', ch.texiao_miaoshu)
|
texiao_miaoshu = request.data.get('texiao_miaoshu', ch.texiao_miaoshu)
|
||||||
kaohe_guize = request.data.get('kaohe_guize', ch.kaohe_guize)
|
kaohe_guize = request.data.get('kaohe_guize', ch.kaohe_guize)
|
||||||
kaioi_jinpai = request.data.get('kaioi_jinpai', ch.kaioi_jinpai)
|
kaioi_jinpai = request.data.get('kaioi_jinpai', ch.kaioi_jinpai)
|
||||||
|
|
||||||
if not mingcheng:
|
if not mingcheng:
|
||||||
return Response({'code': 1, 'msg': '称号名称不能为空'}, status=400)
|
return Response({'code': 1, 'msg': '称号名称不能为空'}, status=400)
|
||||||
if leixing not in ['dashou','boss','shangjia','guanshi','zuzhang']:
|
if leixing is None:
|
||||||
|
logger.warning('ChzsgcView update 无效 leixing=%r ch_id=%s', raw_leixing, ch_id)
|
||||||
return Response({'code': 1, 'msg': '无效的称号类型'}, status=400)
|
return Response({'code': 1, 'msg': '无效的称号类型'}, status=400)
|
||||||
|
if bankuai_id is False:
|
||||||
|
return Response({'code': 1, 'msg': '板块不存在'}, status=400)
|
||||||
if Chenghao.query.filter(mingcheng=mingcheng).exclude(id=ch_id).exists():
|
if Chenghao.query.filter(mingcheng=mingcheng).exclude(id=ch_id).exists():
|
||||||
return Response({'code': 1, 'msg': '称号名称已存在'}, status=400)
|
return Response({'code': 1, 'msg': '称号名称已存在'}, status=400)
|
||||||
|
|
||||||
@@ -265,7 +328,7 @@ class ChzsgcView(APIView):
|
|||||||
ch.bankuai_id = bankuai_id
|
ch.bankuai_id = bankuai_id
|
||||||
ch.texiao_miaoshu = texiao_miaoshu
|
ch.texiao_miaoshu = texiao_miaoshu
|
||||||
ch.kaohe_guize = kaohe_guize
|
ch.kaohe_guize = kaohe_guize
|
||||||
ch.kaioi_jinpai = kaioi_jinpai
|
ch.kaioi_jinpai = bool(kaioi_jinpai)
|
||||||
ch.save()
|
ch.save()
|
||||||
|
|
||||||
# 如果前端传递了 feiyong_list,则全量替换费用
|
# 如果前端传递了 feiyong_list,则全量替换费用
|
||||||
@@ -284,7 +347,7 @@ class ChzsgcView(APIView):
|
|||||||
|
|
||||||
# ========== 删除称号 ==========
|
# ========== 删除称号 ==========
|
||||||
elif action == 'delete':
|
elif action == 'delete':
|
||||||
ch_id = request.data.get('chenghao_id')
|
ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
|
||||||
if not ch_id:
|
if not ch_id:
|
||||||
return Response({'code': 1, 'msg': '缺少称号ID'}, status=400)
|
return Response({'code': 1, 'msg': '缺少称号ID'}, status=400)
|
||||||
try:
|
try:
|
||||||
@@ -295,11 +358,11 @@ class ChzsgcView(APIView):
|
|||||||
return Response({'code': 0, 'msg': '删除成功'})
|
return Response({'code': 0, 'msg': '删除成功'})
|
||||||
|
|
||||||
# ========== 添加/修改某次费用 ==========
|
# ========== 添加/修改某次费用 ==========
|
||||||
elif action == 'add_feiyong':
|
elif action in ('add_feiyong', 'update_feiyong'):
|
||||||
ch_id = request.data.get('chenghao_id')
|
ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
|
||||||
cishu = request.data.get('cishu')
|
cishu = request.data.get('cishu')
|
||||||
feiyong = request.data.get('feiyong', 0)
|
feiyong = request.data.get('feiyong', 0)
|
||||||
if not ch_id or not cishu:
|
if not ch_id or cishu is None or cishu == '':
|
||||||
return Response({'code': 1, 'msg': '缺少称号ID或次数'}, status=400)
|
return Response({'code': 1, 'msg': '缺少称号ID或次数'}, status=400)
|
||||||
try:
|
try:
|
||||||
ch = Chenghao.query.get(id=ch_id)
|
ch = Chenghao.query.get(id=ch_id)
|
||||||
@@ -308,16 +371,16 @@ class ChzsgcView(APIView):
|
|||||||
if int(cishu) < 1:
|
if int(cishu) < 1:
|
||||||
return Response({'code': 1, 'msg': '次数必须>=1'}, status=400)
|
return Response({'code': 1, 'msg': '次数必须>=1'}, status=400)
|
||||||
KaoheCishuFeiyong.query.update_or_create(
|
KaoheCishuFeiyong.query.update_or_create(
|
||||||
chenghao=ch, cishu=cishu,
|
chenghao=ch, cishu=int(cishu),
|
||||||
defaults={'feiyong': feiyong}
|
defaults={'feiyong': feiyong}
|
||||||
)
|
)
|
||||||
return Response({'code': 0, 'msg': '添加/修改费用成功'})
|
return Response({'code': 0, 'msg': '添加/修改费用成功'})
|
||||||
|
|
||||||
# ========== 删除某次费用 ==========
|
# ========== 删除某次费用 ==========
|
||||||
elif action == 'remove_feiyong':
|
elif action == 'remove_feiyong':
|
||||||
ch_id = request.data.get('chenghao_id')
|
ch_id = _parse_chenghao_id(request.data.get('chenghao_id'))
|
||||||
cishu = request.data.get('cishu')
|
cishu = request.data.get('cishu')
|
||||||
if not ch_id or not cishu:
|
if not ch_id or cishu is None or cishu == '':
|
||||||
return Response({'code': 1, 'msg': '缺少称号ID或次数'}, status=400)
|
return Response({'code': 1, 'msg': '缺少称号ID或次数'}, status=400)
|
||||||
if int(cishu) == 1:
|
if int(cishu) == 1:
|
||||||
return Response({'code': 1, 'msg': '不能删除第1次考核费用'}, status=400)
|
return Response({'code': 1, 'msg': '不能删除第1次考核费用'}, status=400)
|
||||||
@@ -325,13 +388,9 @@ class ChzsgcView(APIView):
|
|||||||
ch = Chenghao.query.get(id=ch_id)
|
ch = Chenghao.query.get(id=ch_id)
|
||||||
except Chenghao.DoesNotExist:
|
except Chenghao.DoesNotExist:
|
||||||
return Response({'code': 1, 'msg': '称号不存在'}, status=404)
|
return Response({'code': 1, 'msg': '称号不存在'}, status=404)
|
||||||
KaoheCishuFeiyong.query.filter(chenghao=ch, cishu=cishu).delete()
|
KaoheCishuFeiyong.query.filter(chenghao=ch, cishu=int(cishu)).delete()
|
||||||
return Response({'code': 0, 'msg': '删除成功'})
|
return Response({'code': 0, 'msg': '删除成功'})
|
||||||
|
|
||||||
# ========== 修改某次费用(等价于add) ==========
|
|
||||||
elif action == 'update_feiyong':
|
|
||||||
return self.add_feiyong(request) # 复用逻辑
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return Response({'code': 1, 'msg': '未知操作'}, status=400)
|
return Response({'code': 1, 'msg': '未知操作'}, status=400)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from django.urls import path
|
|||||||
from .views import ShangpinGonggaoView, AdminUpdateConfigView, AdminUploadImageView, AdminConfigQueryView, \
|
from .views import ShangpinGonggaoView, AdminUpdateConfigView, AdminUploadImageView, AdminConfigQueryView, \
|
||||||
DashouPaihangView,GetAdminZhanghaoView, GetXiugaiJiluView,ShangjiaTianjiaMobanView, ShangjiaMobanListView, \
|
DashouPaihangView,GetAdminZhanghaoView, GetXiugaiJiluView,ShangjiaTianjiaMobanView, ShangjiaMobanListView, \
|
||||||
ShangjiaGengxinMobanView, ShangjiaShanchuMobanView, ShangjiaGenerateLinkView,KehuGetDingdanLianjieView, \
|
ShangjiaGengxinMobanView, ShangjiaShanchuMobanView, ShangjiaGenerateLinkView,KehuGetDingdanLianjieView, \
|
||||||
KehuTianxieDingdanView, GuanZhuAListView, ZuzhangHaibaoView,GuanshiQRCodeView,\
|
KehuTianxieDingdanView, GuanZhuAListView, ZuzhangHaibaoView,GuanshiQRCodeView, CYaoqingQRCodeView,\
|
||||||
GetDynamicConfigView, HaibaoPeizhiView, PopupConfigView, GetWithdrawModeView,CheckPhoneAuthView,\
|
GetDynamicConfigView, HaibaoPeizhiView, PopupConfigView, GetWithdrawModeView,CheckPhoneAuthView,\
|
||||||
ShangjiaLianjieListView, HuashuHqView, HuashuMatchView
|
ShangjiaLianjieListView, HuashuHqView, HuashuMatchView
|
||||||
|
|
||||||
@@ -31,6 +31,7 @@ urlpatterns = [
|
|||||||
path('gzal', GuanZhuAListView.as_view(), name='关注阿龙'),
|
path('gzal', GuanZhuAListView.as_view(), name='关注阿龙'),
|
||||||
path('guanshiewm', GuanshiQRCodeView.as_view(), name='管事生成二维码'),
|
path('guanshiewm', GuanshiQRCodeView.as_view(), name='管事生成二维码'),
|
||||||
path('zuzhanghb', ZuzhangHaibaoView.as_view(), name='组长二维码'),
|
path('zuzhanghb', ZuzhangHaibaoView.as_view(), name='组长二维码'),
|
||||||
|
path('cyaoqingewm', CYaoqingQRCodeView.as_view(), name='点单端邀请二维码'),
|
||||||
path('qdpzhq', GetDynamicConfigView.as_view(), name='小程序获取配置'),
|
path('qdpzhq', GetDynamicConfigView.as_view(), name='小程序获取配置'),
|
||||||
path('haibaopz', HaibaoPeizhiView.as_view(), name='推广海报配置'),
|
path('haibaopz', HaibaoPeizhiView.as_view(), name='推广海报配置'),
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ from .merchant_links import (
|
|||||||
IPUtils, OrderLinkThrottle,
|
IPUtils, OrderLinkThrottle,
|
||||||
)
|
)
|
||||||
from .qrcode_poster import (
|
from .qrcode_poster import (
|
||||||
GuanZhuAListView, GuanshiQRCodeView, ZuzhangHaibaoView,
|
GuanZhuAListView, GuanshiQRCodeView, ZuzhangHaibaoView, CYaoqingQRCodeView,
|
||||||
)
|
)
|
||||||
from .scripts import (
|
from .scripts import (
|
||||||
HuashuHqView, HuashuMatchView,
|
HuashuHqView, HuashuMatchView,
|
||||||
@@ -65,7 +65,7 @@ __all__ = [
|
|||||||
'KehuGetDingdanLianjieView', 'KehuTianxieDingdanView',
|
'KehuGetDingdanLianjieView', 'KehuTianxieDingdanView',
|
||||||
'IPUtils', 'OrderLinkThrottle',
|
'IPUtils', 'OrderLinkThrottle',
|
||||||
# qrcode_poster
|
# qrcode_poster
|
||||||
'GuanZhuAListView', 'GuanshiQRCodeView', 'ZuzhangHaibaoView',
|
'GuanZhuAListView', 'GuanshiQRCodeView', 'ZuzhangHaibaoView', 'CYaoqingQRCodeView',
|
||||||
# scripts
|
# scripts
|
||||||
'HuashuHqView', 'HuashuMatchView',
|
'HuashuHqView', 'HuashuMatchView',
|
||||||
'MiniappScriptHoutaiListView', 'MiniappScriptHoutaiModifyView',
|
'MiniappScriptHoutaiListView', 'MiniappScriptHoutaiModifyView',
|
||||||
|
|||||||
@@ -81,15 +81,23 @@ class ShangpinGonggaoView(APIView):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("收到商品公告和轮播图请求")
|
logger.info("收到商品公告和轮播图请求")
|
||||||
from jituan.services.display_config import get_gonggao_content, get_lunbo_urls, normalize_page_key
|
from jituan.services.display_config import (
|
||||||
|
get_gonggao_content,
|
||||||
|
get_lunbo_urls,
|
||||||
|
lunbo_image_type_for_page,
|
||||||
|
normalize_page_key,
|
||||||
|
)
|
||||||
|
|
||||||
page_key = normalize_page_key(request.data.get('page_key'), image_type=1)
|
page_key = normalize_page_key(request.data.get('page_key'), image_type=1)
|
||||||
|
image_type = lunbo_image_type_for_page(page_key)
|
||||||
gonggao_content = get_gonggao_content(request, notice_type=1, page_key=page_key)
|
gonggao_content = get_gonggao_content(request, notice_type=1, page_key=page_key)
|
||||||
lunbo_urls = get_lunbo_urls(request, page_key=page_key, image_type=1)
|
lunbo_urls = get_lunbo_urls(request, page_key=page_key, image_type=image_type)
|
||||||
|
from jituan.services.club_context import resolve_effective_club_id
|
||||||
response_data = {
|
response_data = {
|
||||||
"shangpingonggao": gonggao_content,
|
"shangpingonggao": gonggao_content,
|
||||||
"shangpinlunbo": lunbo_urls,
|
"shangpinlunbo": lunbo_urls,
|
||||||
"page_key": page_key,
|
"page_key": page_key,
|
||||||
|
"club_id": resolve_effective_club_id(request, None),
|
||||||
}
|
}
|
||||||
logger.info(f"返回数据:公告长度{len(gonggao_content)},轮播图数量{len(lunbo_urls)} page_key={page_key}")
|
logger.info(f"返回数据:公告长度{len(gonggao_content)},轮播图数量{len(lunbo_urls)} page_key={page_key}")
|
||||||
# 4. 返回响应
|
# 4. 返回响应
|
||||||
|
|||||||
@@ -46,8 +46,10 @@ from users.models import (
|
|||||||
)
|
)
|
||||||
from users.business_models import User
|
from users.business_models import User
|
||||||
|
|
||||||
from orders.models import CommissionRate, Order, MerchantOrderExt
|
from orders.models import Order, MerchantOrderExt
|
||||||
from orders.notice_tasks import dingdan_guangbo
|
from orders.notice_tasks import dingdan_guangbo
|
||||||
|
from orders.utils import calc_shangjia_order_fencheng
|
||||||
|
from jituan.services.club_context import resolve_club_id_from_request
|
||||||
|
|
||||||
from rank.models import Chenghao, DingdanBiaoqian
|
from rank.models import Chenghao, DingdanBiaoqian
|
||||||
|
|
||||||
@@ -103,12 +105,9 @@ class ShangjiaGenerateLinkView(APIView):
|
|||||||
if jiage > shangjia.yue:
|
if jiage > shangjia.yue:
|
||||||
return Response({'code': 400, 'msg': f'余额不足,当前余额{shangjia.yue}元', 'data': {}}, status=400)
|
return Response({'code': 400, 'msg': f'余额不足,当前余额{shangjia.yue}元', 'data': {}}, status=400)
|
||||||
|
|
||||||
# 利率
|
# 打手+管事分成(与普通派单 sjpaifa 同一套算法,打手优先)
|
||||||
try:
|
club_id = resolve_club_id_from_request(request)
|
||||||
lilu = float(CommissionRate.query.get(Platform='3').Rate) or 1.0
|
dashou_fencheng, guanshi_fencheng = calc_shangjia_order_fencheng(jiage, club_id=club_id)
|
||||||
except CommissionRate.DoesNotExist:
|
|
||||||
lilu = 1.0
|
|
||||||
dashou_fencheng = Decimal(str(round(float(jiage) * lilu, 2)))
|
|
||||||
|
|
||||||
# 抢单要求:根据模板是否启用佣金
|
# 抢单要求:根据模板是否启用佣金
|
||||||
chenghao_id = moban.TitleID
|
chenghao_id = moban.TitleID
|
||||||
@@ -154,13 +153,14 @@ class ShangjiaGenerateLinkView(APIView):
|
|||||||
dingdan_id = generate_dingdan_id()
|
dingdan_id = generate_dingdan_id()
|
||||||
secure_token = generate_secure_token(dingdan_id, user.UserUID)
|
secure_token = generate_secure_token(dingdan_id, user.UserUID)
|
||||||
|
|
||||||
# 创建订单主表
|
# 创建订单主表(分红在生成链接时锁定,填表入池不再改)
|
||||||
dingdan = Order.query.create(
|
dingdan = Order.query.create(
|
||||||
OrderID=dingdan_id,
|
OrderID=dingdan_id,
|
||||||
Status=13,
|
Status=13,
|
||||||
Platform=2,
|
Platform=2,
|
||||||
Amount=jiage,
|
Amount=jiage,
|
||||||
PlayerCommission=dashou_fencheng,
|
PlayerCommission=dashou_fencheng,
|
||||||
|
ManagerCommission=guanshi_fencheng,
|
||||||
ProductTypeID=shangpin_type_id,
|
ProductTypeID=shangpin_type_id,
|
||||||
GrabRequirement=final_yaoqiuleixing,
|
GrabRequirement=final_yaoqiuleixing,
|
||||||
MembershipID=final_huiyuan_id,
|
MembershipID=final_huiyuan_id,
|
||||||
@@ -168,6 +168,7 @@ class ShangjiaGenerateLinkView(APIView):
|
|||||||
Description=moban.TemplateDesc[:2000],
|
Description=moban.TemplateDesc[:2000],
|
||||||
User1ID=f"Sj{user.UserUID}",
|
User1ID=f"Sj{user.UserUID}",
|
||||||
ImageURL=user.Avatar or '',
|
ImageURL=user.Avatar or '',
|
||||||
|
ClubID=club_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 商家扩展表
|
# 商家扩展表
|
||||||
|
|||||||
@@ -351,9 +351,137 @@ class GuanshiQRCodeView(APIView):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class CYaoqingQRCodeView(APIView):
|
||||||
|
"""
|
||||||
|
点单端老板推荐官小程序码(落地点单首页,按 UserUID 绑定,无邀请码)。
|
||||||
|
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 get_or_create_c_yaoqing
|
||||||
|
from users.models import YonghuCYaoqing
|
||||||
|
|
||||||
|
if not getattr(current_user, 'UserUID', None):
|
||||||
|
return Response(
|
||||||
|
{'code': 500, 'message': '用户身份异常', 'data': None},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
club_id = resolve_effective_club_id(request, current_user)
|
||||||
|
profile = get_or_create_c_yaoqing(current_user)
|
||||||
|
inviter_uid = str(current_user.UserUID)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# 必须进点单首页,禁止进打手/管事页
|
||||||
|
page_path = f'pages/index/index?yaoqingren={urllib.parse.quote(inviter_uid, safe="")}'
|
||||||
|
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('删除旧点单端推荐二维码失败: %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, 'uid': inviter_uid},
|
||||||
|
})
|
||||||
|
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):
|
class ZuzhangHaibaoView(APIView):
|
||||||
|
|||||||
0
gongdan/__init__.py
Normal file
0
gongdan/__init__.py
Normal file
8
gongdan/apps.py
Normal file
8
gongdan/apps.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""投诉工单应用。"""
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'gongdan'
|
||||||
|
verbose_name = '投诉工单'
|
||||||
42
gongdan/constants.py
Normal file
42
gongdan/constants.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""投诉工单常量。"""
|
||||||
|
|
||||||
|
# 投诉类型
|
||||||
|
TYPE_ORDER = 1 # 订单投诉(需订单 OrderID)
|
||||||
|
TYPE_RECHARGE = 2 # 充值投诉(需 Czjilu.dingdan_id)
|
||||||
|
TYPE_PENALTY = 3 # 罚款投诉(需罚单 Penalty.id)
|
||||||
|
TYPE_GUANSHI = 4 # 管事投诉
|
||||||
|
TYPE_FRAUD = 5 # 被骗投诉
|
||||||
|
|
||||||
|
TYPE_CHOICES = (
|
||||||
|
(TYPE_ORDER, '订单投诉'),
|
||||||
|
(TYPE_RECHARGE, '充值投诉'),
|
||||||
|
(TYPE_PENALTY, '罚款投诉'),
|
||||||
|
(TYPE_GUANSHI, '管事投诉'),
|
||||||
|
(TYPE_FRAUD, '被骗投诉'),
|
||||||
|
)
|
||||||
|
|
||||||
|
TYPE_LABELS = dict(TYPE_CHOICES)
|
||||||
|
|
||||||
|
# 状态:1处理中 2已处理 3不满意(可升级)4已关闭
|
||||||
|
STATUS_PROCESSING = 1
|
||||||
|
STATUS_DONE = 2
|
||||||
|
STATUS_DISSATISFIED = 3
|
||||||
|
STATUS_CLOSED = 4
|
||||||
|
|
||||||
|
STATUS_CHOICES = (
|
||||||
|
(STATUS_PROCESSING, '处理中'),
|
||||||
|
(STATUS_DONE, '已处理'),
|
||||||
|
(STATUS_DISSATISFIED, '不满意'),
|
||||||
|
(STATUS_CLOSED, '已关闭'),
|
||||||
|
)
|
||||||
|
|
||||||
|
STATUS_LABELS = dict(STATUS_CHOICES)
|
||||||
|
|
||||||
|
MAX_IMAGES = 9
|
||||||
|
MAX_SHUOMING_LEN = 500
|
||||||
|
MAX_SHENGJI_TIMES = 5
|
||||||
|
OSS_PREFIX = 'gongdan'
|
||||||
|
|
||||||
|
# 权限码
|
||||||
|
PERM_VIEW = 'gdck666' # 查看工单
|
||||||
|
PERM_HANDLE = 'gdcl666' # 处理工单
|
||||||
68
gongdan/migrations/0001_initial.py
Normal file
68
gongdan/migrations/0001_initial.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Generated manually for gongdan
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Gongdan',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('gongdan_id', models.CharField(db_index=True, max_length=32, unique=True, verbose_name='工单号')),
|
||||||
|
('leixing', models.IntegerField(choices=[(1, '订单投诉'), (2, '充值投诉'), (3, '罚款投诉'), (4, '管事投诉'), (5, '被骗投诉')], db_index=True, verbose_name='投诉类型')),
|
||||||
|
('order_id', models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='订单ID')),
|
||||||
|
('chongzhi_id', models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='充值单ID')),
|
||||||
|
('fadan_id', models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name='罚单ID')),
|
||||||
|
('guanshi_id', models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name='被投诉管事UID')),
|
||||||
|
('shuoming', models.CharField(max_length=500, verbose_name='投诉说明')),
|
||||||
|
('shenqingren_id', models.CharField(db_index=True, max_length=32, verbose_name='申请人UserUID')),
|
||||||
|
('club_id', models.CharField(blank=True, db_index=True, max_length=16, null=True, verbose_name='俱乐部')),
|
||||||
|
('zhuangtai', models.IntegerField(choices=[(1, '处理中'), (2, '已处理'), (3, '不满意'), (4, '已关闭')], db_index=True, default=1, verbose_name='状态')),
|
||||||
|
('chuli_jieguo', models.CharField(blank=True, max_length=500, null=True, verbose_name='处理结果说明')),
|
||||||
|
('chuliren_id', models.CharField(blank=True, db_index=True, max_length=32, null=True, verbose_name='处理人UID')),
|
||||||
|
('chuliren_name', models.CharField(blank=True, max_length=64, null=True, verbose_name='处理人展示名')),
|
||||||
|
('chuli_time', models.DateTimeField(blank=True, null=True, verbose_name='处理时间')),
|
||||||
|
('shengji_cishu', models.IntegerField(default=0, verbose_name='升级次数')),
|
||||||
|
('CreateTime', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={'db_table': 'gongdan', 'verbose_name': '投诉工单', 'ordering': ['-CreateTime']},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GongdanImage',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('gongdan_hao', models.CharField(db_index=True, max_length=32, verbose_name='工单号冗余')),
|
||||||
|
('url', models.CharField(max_length=512, verbose_name='图片URL')),
|
||||||
|
('paixu', models.IntegerField(default=0, verbose_name='排序')),
|
||||||
|
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('gongdan', models.ForeignKey(db_column='gongdan_pk', on_delete=django.db.models.deletion.CASCADE, related_name='images', to='gongdan.gongdan')),
|
||||||
|
],
|
||||||
|
options={'db_table': 'gongdan_image', 'verbose_name': '工单证据图', 'ordering': ['paixu', 'id']},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GongdanShengji',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('gongdan_hao', models.CharField(db_index=True, max_length=32, verbose_name='工单号冗余')),
|
||||||
|
('cishu', models.IntegerField(verbose_name='第几次升级')),
|
||||||
|
('shuoming', models.CharField(max_length=500, verbose_name='补充说明')),
|
||||||
|
('shenqingren_id', models.CharField(db_index=True, max_length=32, verbose_name='申请人')),
|
||||||
|
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('gongdan', models.ForeignKey(db_column='gongdan_pk', on_delete=django.db.models.deletion.CASCADE, related_name='shengji_list', to='gongdan.gongdan')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'gongdan_shengji',
|
||||||
|
'verbose_name': '工单升级记录',
|
||||||
|
'ordering': ['cishu', 'id'],
|
||||||
|
'unique_together': {('gongdan', 'cishu')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
gongdan/migrations/__init__.py
Normal file
0
gongdan/migrations/__init__.py
Normal file
72
gongdan/models.py
Normal file
72
gongdan/models.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""投诉工单模型:主单 + 证据图 + 升级补充说明。"""
|
||||||
|
from django.db import models
|
||||||
|
from gvsdsdk.model_base import QModel
|
||||||
|
|
||||||
|
from gongdan.constants import STATUS_PROCESSING, TYPE_CHOICES, STATUS_CHOICES
|
||||||
|
|
||||||
|
|
||||||
|
class Gongdan(QModel):
|
||||||
|
"""投诉工单主表。"""
|
||||||
|
gongdan_id = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='工单号')
|
||||||
|
leixing = models.IntegerField(db_index=True, choices=TYPE_CHOICES, verbose_name='投诉类型')
|
||||||
|
# 按类型选填关联业务 ID(字符串统一存,便于展示与复制)
|
||||||
|
order_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='订单ID')
|
||||||
|
chongzhi_id = models.CharField(max_length=64, null=True, blank=True, db_index=True, verbose_name='充值单ID')
|
||||||
|
fadan_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='罚单ID')
|
||||||
|
guanshi_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='被投诉管事UID')
|
||||||
|
|
||||||
|
shuoming = models.CharField(max_length=500, verbose_name='投诉说明')
|
||||||
|
shenqingren_id = models.CharField(max_length=32, db_index=True, verbose_name='申请人UserUID')
|
||||||
|
club_id = models.CharField(max_length=16, null=True, blank=True, db_index=True, verbose_name='俱乐部')
|
||||||
|
|
||||||
|
zhuangtai = models.IntegerField(
|
||||||
|
default=STATUS_PROCESSING, db_index=True, choices=STATUS_CHOICES, verbose_name='状态',
|
||||||
|
)
|
||||||
|
chuli_jieguo = models.CharField(max_length=500, null=True, blank=True, verbose_name='处理结果说明')
|
||||||
|
chuliren_id = models.CharField(max_length=32, null=True, blank=True, db_index=True, verbose_name='处理人UID')
|
||||||
|
chuliren_name = models.CharField(max_length=64, null=True, blank=True, verbose_name='处理人展示名')
|
||||||
|
chuli_time = models.DateTimeField(null=True, blank=True, verbose_name='处理时间')
|
||||||
|
|
||||||
|
shengji_cishu = models.IntegerField(default=0, verbose_name='升级次数')
|
||||||
|
CreateTime = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||||
|
UpdateTime = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'gongdan'
|
||||||
|
verbose_name = '投诉工单'
|
||||||
|
ordering = ['-CreateTime']
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanImage(QModel):
|
||||||
|
"""工单证据图(最多9张,以主单当前版本为准;升级时可整体替换)。"""
|
||||||
|
gongdan = models.ForeignKey(
|
||||||
|
Gongdan, on_delete=models.CASCADE, related_name='images', db_column='gongdan_pk',
|
||||||
|
)
|
||||||
|
# 冗余业务工单号(勿命名 gongdan_id,会与 FK 访问器冲突)
|
||||||
|
gongdan_hao = models.CharField(max_length=32, db_index=True, verbose_name='工单号冗余')
|
||||||
|
url = models.CharField(max_length=512, verbose_name='图片URL')
|
||||||
|
paixu = models.IntegerField(default=0, verbose_name='排序')
|
||||||
|
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'gongdan_image'
|
||||||
|
verbose_name = '工单证据图'
|
||||||
|
ordering = ['paixu', 'id']
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanShengji(QModel):
|
||||||
|
"""升级/继续投诉补充说明(只记文字与次数;图片改主表 images)。"""
|
||||||
|
gongdan = models.ForeignKey(
|
||||||
|
Gongdan, on_delete=models.CASCADE, related_name='shengji_list', db_column='gongdan_pk',
|
||||||
|
)
|
||||||
|
gongdan_hao = models.CharField(max_length=32, db_index=True, verbose_name='工单号冗余')
|
||||||
|
cishu = models.IntegerField(verbose_name='第几次升级')
|
||||||
|
shuoming = models.CharField(max_length=500, verbose_name='补充说明')
|
||||||
|
shenqingren_id = models.CharField(max_length=32, db_index=True, verbose_name='申请人')
|
||||||
|
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'gongdan_shengji'
|
||||||
|
verbose_name = '工单升级记录'
|
||||||
|
ordering = ['cishu', 'id']
|
||||||
|
unique_together = [['gongdan', 'cishu']]
|
||||||
292
gongdan/services.py
Normal file
292
gongdan/services.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""工单业务:创建 / 升级 / 客服处理(幂等与校验)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from gongdan.constants import (
|
||||||
|
TYPE_ORDER, TYPE_RECHARGE, TYPE_PENALTY, TYPE_GUANSHI, TYPE_FRAUD,
|
||||||
|
TYPE_LABELS, STATUS_PROCESSING, STATUS_DONE, STATUS_DISSATISFIED, STATUS_CLOSED,
|
||||||
|
STATUS_LABELS, MAX_IMAGES, MAX_SHUOMING_LEN, MAX_SHENGJI_TIMES, OSS_PREFIX,
|
||||||
|
)
|
||||||
|
from gongdan.models import Gongdan, GongdanImage, GongdanShengji
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanError(Exception):
|
||||||
|
def __init__(self, message, code='error'):
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_gongdan_id():
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
ns = int(time.time_ns() // 1000) % 1000000
|
||||||
|
rnd = random.randint(0, 99)
|
||||||
|
return f'GD{ts:011d}{ns:06d}{rnd:02d}'
|
||||||
|
|
||||||
|
|
||||||
|
def _trim(text, max_len=MAX_SHUOMING_LEN):
|
||||||
|
return (text or '').strip()[:max_len]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_type_refs(leixing, order_id=None, chongzhi_id=None, fadan_id=None, guanshi_id=None):
|
||||||
|
"""按类型校验必填关联 ID,并尽量核对库内是否存在。"""
|
||||||
|
order_id = (order_id or '').strip() or None
|
||||||
|
chongzhi_id = (chongzhi_id or '').strip() or None
|
||||||
|
fadan_id = (fadan_id or '').strip() or None
|
||||||
|
guanshi_id = (guanshi_id or '').strip() or None
|
||||||
|
|
||||||
|
if leixing == TYPE_ORDER:
|
||||||
|
if not order_id:
|
||||||
|
raise GongdanError('订单投诉请填写订单ID', 'need_order_id')
|
||||||
|
from orders.models import Order
|
||||||
|
if not Order.query.filter(OrderID=order_id).exists():
|
||||||
|
raise GongdanError('订单不存在,请核对订单ID', 'order_not_found')
|
||||||
|
return order_id, None, None, None
|
||||||
|
|
||||||
|
if leixing == TYPE_RECHARGE:
|
||||||
|
if not chongzhi_id:
|
||||||
|
raise GongdanError('充值投诉请填写充值记录ID', 'need_chongzhi_id')
|
||||||
|
from products.models import Czjilu
|
||||||
|
if not Czjilu.query.filter(dingdan_id=chongzhi_id).exists():
|
||||||
|
raise GongdanError('充值记录不存在,请核对充值ID', 'chongzhi_not_found')
|
||||||
|
return None, chongzhi_id, None, None
|
||||||
|
|
||||||
|
if leixing == TYPE_PENALTY:
|
||||||
|
if not fadan_id:
|
||||||
|
raise GongdanError('罚款投诉请填写罚单ID', 'need_fadan_id')
|
||||||
|
from orders.models import Penalty
|
||||||
|
try:
|
||||||
|
fid = int(fadan_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise GongdanError('罚单ID格式不正确', 'bad_fadan_id')
|
||||||
|
if not Penalty.query.filter(id=fid).exists():
|
||||||
|
raise GongdanError('罚单不存在,请核对罚单ID', 'fadan_not_found')
|
||||||
|
return None, None, str(fid), None
|
||||||
|
|
||||||
|
if leixing == TYPE_GUANSHI:
|
||||||
|
# 管事UID 选填,有则校验存在
|
||||||
|
if guanshi_id:
|
||||||
|
from users.models import UserGuanshi
|
||||||
|
if not UserGuanshi.objects.filter(user__UserUID=guanshi_id).exists():
|
||||||
|
raise GongdanError('管事不存在,请核对管事ID', 'guanshi_not_found')
|
||||||
|
return None, None, None, guanshi_id
|
||||||
|
|
||||||
|
if leixing == TYPE_FRAUD:
|
||||||
|
return None, None, None, None
|
||||||
|
|
||||||
|
raise GongdanError('不支持的投诉类型', 'bad_type')
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_gongdan(gd, include_images=True, include_shengji=False):
|
||||||
|
data = {
|
||||||
|
'gongdan_id': gd.gongdan_id,
|
||||||
|
'leixing': gd.leixing,
|
||||||
|
'leixing_label': TYPE_LABELS.get(gd.leixing, str(gd.leixing)),
|
||||||
|
'order_id': gd.order_id or '',
|
||||||
|
'chongzhi_id': gd.chongzhi_id or '',
|
||||||
|
'fadan_id': gd.fadan_id or '',
|
||||||
|
'guanshi_id': gd.guanshi_id or '',
|
||||||
|
'shuoming': gd.shuoming or '',
|
||||||
|
'shenqingren_id': gd.shenqingren_id,
|
||||||
|
'club_id': gd.club_id or '',
|
||||||
|
'zhuangtai': gd.zhuangtai,
|
||||||
|
'zhuangtai_label': STATUS_LABELS.get(gd.zhuangtai, str(gd.zhuangtai)),
|
||||||
|
'chuli_jieguo': gd.chuli_jieguo or '',
|
||||||
|
'chuliren_id': gd.chuliren_id or '',
|
||||||
|
'chuliren_name': gd.chuliren_name or '',
|
||||||
|
'chuli_time': gd.chuli_time.isoformat() if gd.chuli_time else '',
|
||||||
|
'shengji_cishu': gd.shengji_cishu or 0,
|
||||||
|
'CreateTime': gd.CreateTime.isoformat() if gd.CreateTime else '',
|
||||||
|
'UpdateTime': gd.UpdateTime.isoformat() if gd.UpdateTime else '',
|
||||||
|
}
|
||||||
|
if include_images:
|
||||||
|
data['images'] = [
|
||||||
|
{'url': img.url, 'paixu': img.paixu}
|
||||||
|
for img in gd.images.all().order_by('paixu', 'id')
|
||||||
|
]
|
||||||
|
if include_shengji:
|
||||||
|
data['shengji_list'] = [
|
||||||
|
{
|
||||||
|
'cishu': s.cishu,
|
||||||
|
'shuoming': s.shuoming,
|
||||||
|
'CreateTime': s.CreateTime.isoformat() if s.CreateTime else '',
|
||||||
|
}
|
||||||
|
for s in gd.shengji_list.all().order_by('cishu', 'id')
|
||||||
|
]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_images(gd, image_urls):
|
||||||
|
urls = [u.strip() for u in (image_urls or []) if u and str(u).strip()]
|
||||||
|
if len(urls) > MAX_IMAGES:
|
||||||
|
raise GongdanError(f'最多上传{MAX_IMAGES}张图片', 'too_many_images')
|
||||||
|
GongdanImage.objects.filter(gongdan=gd).delete()
|
||||||
|
for i, url in enumerate(urls):
|
||||||
|
GongdanImage.objects.create(
|
||||||
|
gongdan=gd,
|
||||||
|
gongdan_hao=gd.gongdan_id,
|
||||||
|
url=url[:512],
|
||||||
|
paixu=i,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def create_gongdan(
|
||||||
|
*,
|
||||||
|
yonghuid,
|
||||||
|
leixing,
|
||||||
|
shuoming,
|
||||||
|
club_id=None,
|
||||||
|
order_id=None,
|
||||||
|
chongzhi_id=None,
|
||||||
|
fadan_id=None,
|
||||||
|
guanshi_id=None,
|
||||||
|
image_urls=None,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
leixing = int(leixing)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise GongdanError('投诉类型无效', 'bad_type')
|
||||||
|
if leixing not in TYPE_LABELS:
|
||||||
|
raise GongdanError('投诉类型无效', 'bad_type')
|
||||||
|
|
||||||
|
text = _trim(shuoming)
|
||||||
|
if not text:
|
||||||
|
raise GongdanError('请填写投诉说明', 'need_shuoming')
|
||||||
|
|
||||||
|
order_id, chongzhi_id, fadan_id, guanshi_id = validate_type_refs(
|
||||||
|
leixing, order_id, chongzhi_id, fadan_id, guanshi_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
gid = _gen_gongdan_id()
|
||||||
|
for _ in range(5):
|
||||||
|
if not Gongdan.query.filter(gongdan_id=gid).exists():
|
||||||
|
break
|
||||||
|
gid = _gen_gongdan_id()
|
||||||
|
|
||||||
|
gd = Gongdan.objects.create(
|
||||||
|
gongdan_id=gid,
|
||||||
|
leixing=leixing,
|
||||||
|
order_id=order_id,
|
||||||
|
chongzhi_id=chongzhi_id,
|
||||||
|
fadan_id=fadan_id,
|
||||||
|
guanshi_id=guanshi_id,
|
||||||
|
shuoming=text,
|
||||||
|
shenqingren_id=yonghuid,
|
||||||
|
club_id=club_id or None,
|
||||||
|
zhuangtai=STATUS_PROCESSING,
|
||||||
|
shengji_cishu=0,
|
||||||
|
)
|
||||||
|
_replace_images(gd, image_urls)
|
||||||
|
logger.info('创建工单 %s type=%s user=%s', gid, leixing, yonghuid)
|
||||||
|
return gd
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def escalate_gongdan(*, gongdan_id, yonghuid, shuoming, image_urls=None):
|
||||||
|
"""不满意后继续投诉:写升级记录;可选整体替换证据图。"""
|
||||||
|
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||||
|
if not gd:
|
||||||
|
raise GongdanError('工单不存在', 'not_found')
|
||||||
|
if gd.shenqingren_id != yonghuid:
|
||||||
|
raise GongdanError('无权操作此工单', 'forbidden')
|
||||||
|
if gd.zhuangtai not in (STATUS_DONE, STATUS_DISSATISFIED):
|
||||||
|
raise GongdanError('当前状态不可继续投诉,请等待客服处理', 'bad_status')
|
||||||
|
if (gd.shengji_cishu or 0) >= MAX_SHENGJI_TIMES:
|
||||||
|
raise GongdanError(f'最多升级{MAX_SHENGJI_TIMES}次,请联系客服', 'too_many_shengji')
|
||||||
|
|
||||||
|
text = _trim(shuoming)
|
||||||
|
if not text:
|
||||||
|
raise GongdanError('请填写补充说明', 'need_shuoming')
|
||||||
|
|
||||||
|
next_cishu = (gd.shengji_cishu or 0) + 1
|
||||||
|
GongdanShengji.objects.create(
|
||||||
|
gongdan=gd,
|
||||||
|
gongdan_hao=gd.gongdan_id,
|
||||||
|
cishu=next_cishu,
|
||||||
|
shuoming=text,
|
||||||
|
shenqingren_id=yonghuid,
|
||||||
|
)
|
||||||
|
gd.shengji_cishu = next_cishu
|
||||||
|
gd.zhuangtai = STATUS_PROCESSING
|
||||||
|
gd.chuli_jieguo = ''
|
||||||
|
gd.chuli_time = None
|
||||||
|
# 保留原处理人记录便于审计,但状态回到处理中
|
||||||
|
gd.save(update_fields=[
|
||||||
|
'shengji_cishu', 'zhuangtai', 'chuli_jieguo', 'chuli_time', 'UpdateTime',
|
||||||
|
])
|
||||||
|
if image_urls is not None:
|
||||||
|
_replace_images(gd, image_urls)
|
||||||
|
logger.info('工单升级 %s cishu=%s', gongdan_id, next_cishu)
|
||||||
|
return gd
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def handle_gongdan(
|
||||||
|
*,
|
||||||
|
gongdan_id,
|
||||||
|
handler_uid,
|
||||||
|
handler_name,
|
||||||
|
action,
|
||||||
|
chuli_jieguo='',
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
客服处理:
|
||||||
|
action=done → 已处理
|
||||||
|
action=dissatisfied → 标记不满意(一般由用户侧触发,客服 sparingly)
|
||||||
|
action=close → 关闭
|
||||||
|
action=processing → 重新标处理中
|
||||||
|
"""
|
||||||
|
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||||
|
if not gd:
|
||||||
|
raise GongdanError('工单不存在', 'not_found')
|
||||||
|
|
||||||
|
action = (action or '').strip().lower()
|
||||||
|
jieguo = _trim(chuli_jieguo)
|
||||||
|
|
||||||
|
if action in ('done', 'finish', 'resolve'):
|
||||||
|
if not jieguo:
|
||||||
|
raise GongdanError('请填写处理结果', 'need_jieguo')
|
||||||
|
gd.zhuangtai = STATUS_DONE
|
||||||
|
gd.chuli_jieguo = jieguo
|
||||||
|
gd.chuliren_id = handler_uid
|
||||||
|
gd.chuliren_name = (handler_name or '')[:64]
|
||||||
|
gd.chuli_time = timezone.now()
|
||||||
|
elif action in ('close',):
|
||||||
|
gd.zhuangtai = STATUS_CLOSED
|
||||||
|
gd.chuli_jieguo = jieguo or gd.chuli_jieguo or '已关闭'
|
||||||
|
gd.chuliren_id = handler_uid
|
||||||
|
gd.chuliren_name = (handler_name or '')[:64]
|
||||||
|
gd.chuli_time = timezone.now()
|
||||||
|
elif action in ('processing', 'reopen'):
|
||||||
|
gd.zhuangtai = STATUS_PROCESSING
|
||||||
|
elif action in ('dissatisfied',):
|
||||||
|
gd.zhuangtai = STATUS_DISSATISFIED
|
||||||
|
else:
|
||||||
|
raise GongdanError('未知处理动作', 'bad_action')
|
||||||
|
|
||||||
|
gd.save()
|
||||||
|
logger.info('工单处理 %s action=%s by=%s', gongdan_id, action, handler_uid)
|
||||||
|
return gd
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def user_mark_dissatisfied(*, gongdan_id, yonghuid):
|
||||||
|
gd = Gongdan.query.select_for_update().filter(gongdan_id=gongdan_id).first()
|
||||||
|
if not gd:
|
||||||
|
raise GongdanError('工单不存在', 'not_found')
|
||||||
|
if gd.shenqingren_id != yonghuid:
|
||||||
|
raise GongdanError('无权操作此工单', 'forbidden')
|
||||||
|
if gd.zhuangtai != STATUS_DONE:
|
||||||
|
raise GongdanError('仅已处理的工单可标记不满意', 'bad_status')
|
||||||
|
gd.zhuangtai = STATUS_DISSATISFIED
|
||||||
|
gd.save(update_fields=['zhuangtai', 'UpdateTime'])
|
||||||
|
return gd
|
||||||
27
gongdan/urls.py
Normal file
27
gongdan/urls.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from gongdan.views import (
|
||||||
|
GongdanUploadImageView,
|
||||||
|
GongdanCreateView,
|
||||||
|
GongdanMyListView,
|
||||||
|
GongdanDetailView,
|
||||||
|
GongdanEscalateView,
|
||||||
|
GongdanDissatisfiedView,
|
||||||
|
GongdanMetaView,
|
||||||
|
ChongzhiRecordListView,
|
||||||
|
GongdanAdminListView,
|
||||||
|
GongdanAdminHandleView,
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('upload-image/', GongdanUploadImageView.as_view(), name='工单图片上传'),
|
||||||
|
path('create/', GongdanCreateView.as_view(), name='创建投诉工单'),
|
||||||
|
path('my-list/', GongdanMyListView.as_view(), name='我的投诉列表'),
|
||||||
|
path('detail/', GongdanDetailView.as_view(), name='工单详情'),
|
||||||
|
path('escalate/', GongdanEscalateView.as_view(), name='继续投诉升级'),
|
||||||
|
path('dissatisfied/', GongdanDissatisfiedView.as_view(), name='标记不满意'),
|
||||||
|
path('meta/', GongdanMetaView.as_view(), name='工单枚举'),
|
||||||
|
path('chongzhi-list/', ChongzhiRecordListView.as_view(), name='我的充值记录'),
|
||||||
|
path('admin/list/', GongdanAdminListView.as_view(), name='客服工单列表'),
|
||||||
|
path('admin/handle/', GongdanAdminHandleView.as_view(), name='客服处理工单'),
|
||||||
|
]
|
||||||
367
gongdan/views.py
Normal file
367
gongdan/views.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
"""投诉工单 API:用户提交/列表/升级 + 图片上传 + 客服处理。"""
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
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 rest_framework import status
|
||||||
|
|
||||||
|
from gongdan.constants import (
|
||||||
|
MAX_IMAGES, OSS_PREFIX, PERM_VIEW, PERM_HANDLE, TYPE_LABELS, STATUS_LABELS,
|
||||||
|
)
|
||||||
|
from gongdan.models import Gongdan
|
||||||
|
from gongdan.services import (
|
||||||
|
GongdanError,
|
||||||
|
create_gongdan,
|
||||||
|
escalate_gongdan,
|
||||||
|
handle_gongdan,
|
||||||
|
serialize_gongdan,
|
||||||
|
user_mark_dissatisfied,
|
||||||
|
)
|
||||||
|
from utils.oss_utils import upload_to_oss, validate_image
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(data=None, message='ok', code=200):
|
||||||
|
body = {'code': code, 'message': message, 'msg': message}
|
||||||
|
if data is not None:
|
||||||
|
body['data'] = data
|
||||||
|
return Response(body, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
def _err(message, http=400, code=None):
|
||||||
|
c = code if code is not None else http
|
||||||
|
return Response(
|
||||||
|
{'code': c, 'message': message, 'msg': message},
|
||||||
|
status=http,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_club(request):
|
||||||
|
try:
|
||||||
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
|
return resolve_club_id_for_write(request, request.user)
|
||||||
|
except Exception:
|
||||||
|
return getattr(request.user, 'club_id', None) or None
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanUploadImageView(APIView):
|
||||||
|
"""上传证据图,返回 URL。路径:POST /gongdan/upload-image/"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = (MultiPartParser, FormParser)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
f = request.FILES.get('file') or request.FILES.get('image') or request.FILES.get('tupian')
|
||||||
|
if not f:
|
||||||
|
return _err('请选择图片')
|
||||||
|
ok, msg = validate_image(f)
|
||||||
|
if not ok:
|
||||||
|
return _err(msg)
|
||||||
|
ext = (f.name.rsplit('.', 1)[-1] if '.' in f.name else 'jpg').lower()
|
||||||
|
if ext not in ('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'):
|
||||||
|
ext = 'jpg'
|
||||||
|
uid = getattr(request.user, 'UserUID', 'anon')
|
||||||
|
day = timezone.now().strftime('%Y%m%d')
|
||||||
|
path = f'{OSS_PREFIX}/{day}/{uid}/{uuid.uuid4().hex}.{ext}'
|
||||||
|
url = upload_to_oss(f, path)
|
||||||
|
if not url:
|
||||||
|
return _err('上传失败,请稍后重试', http=500)
|
||||||
|
return _ok({'url': url}, '上传成功')
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanCreateView(APIView):
|
||||||
|
"""创建投诉工单。POST /gongdan/create/"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = (JSONParser, FormParser, MultiPartParser)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
d = request.data
|
||||||
|
images = d.get('images') or d.get('image_urls') or []
|
||||||
|
if isinstance(images, str):
|
||||||
|
images = [x.strip() for x in images.split(',') if x.strip()]
|
||||||
|
try:
|
||||||
|
gd = create_gongdan(
|
||||||
|
yonghuid=request.user.UserUID,
|
||||||
|
leixing=d.get('leixing'),
|
||||||
|
shuoming=d.get('shuoming') or d.get('content') or '',
|
||||||
|
club_id=_resolve_club(request),
|
||||||
|
order_id=d.get('order_id') or d.get('OrderID'),
|
||||||
|
chongzhi_id=d.get('chongzhi_id') or d.get('dingdanid') or d.get('dingdan_id'),
|
||||||
|
fadan_id=d.get('fadan_id') or d.get('penalty_id'),
|
||||||
|
guanshi_id=d.get('guanshi_id'),
|
||||||
|
image_urls=images,
|
||||||
|
)
|
||||||
|
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '提交成功')
|
||||||
|
except GongdanError as e:
|
||||||
|
return _err(str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('创建工单失败: %s', e, exc_info=True)
|
||||||
|
return _err('提交失败,请稍后重试', http=500)
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanMyListView(APIView):
|
||||||
|
"""我的投诉列表。POST/GET /gongdan/my-list/"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return self._list(request)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
return self._list(request)
|
||||||
|
|
||||||
|
def _list(self, request):
|
||||||
|
page = int(request.data.get('page') or request.query_params.get('page') or 1)
|
||||||
|
page_size = min(int(request.data.get('page_size') or request.query_params.get('page_size') or 20), 50)
|
||||||
|
qs = Gongdan.query.filter(shenqingren_id=request.user.UserUID).order_by('-CreateTime')
|
||||||
|
total = qs.count()
|
||||||
|
start = max(page - 1, 0) * page_size
|
||||||
|
rows = qs[start:start + page_size]
|
||||||
|
return _ok({
|
||||||
|
'list': [serialize_gongdan(g, include_images=False) for g in rows],
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'page_size': page_size,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanDetailView(APIView):
|
||||||
|
"""工单详情。POST /gongdan/detail/ {gongdan_id}"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
gid = (request.data.get('gongdan_id') or '').strip()
|
||||||
|
if not gid:
|
||||||
|
return _err('工单号不能为空')
|
||||||
|
gd = Gongdan.query.filter(gongdan_id=gid).first()
|
||||||
|
if not gd:
|
||||||
|
return _err('工单不存在', http=404)
|
||||||
|
# 申请人或有客服查看权限
|
||||||
|
if gd.shenqingren_id != request.user.UserUID:
|
||||||
|
from backend.utils import verify_kefu_permission
|
||||||
|
from gongdan.constants import PERM_VIEW, PERM_HANDLE
|
||||||
|
kefu, perms_or_resp = verify_kefu_permission(
|
||||||
|
request, request.data.get('username') or getattr(request.user, 'Phone', None),
|
||||||
|
)
|
||||||
|
if not kefu:
|
||||||
|
return perms_or_resp
|
||||||
|
perms = perms_or_resp if isinstance(perms_or_resp, list) else []
|
||||||
|
if PERM_VIEW not in perms and PERM_HANDLE not in perms and '000001' not in perms:
|
||||||
|
# 超管占位:is admin via kefu object
|
||||||
|
is_admin = getattr(request.user, 'UserType', '') == 'admin' or getattr(request.user, 'IsSuperuser', False)
|
||||||
|
if not is_admin:
|
||||||
|
return _err('无权查看', http=403)
|
||||||
|
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True))
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanEscalateView(APIView):
|
||||||
|
"""继续投诉/升级。POST /gongdan/escalate/"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
d = request.data
|
||||||
|
images = d.get('images')
|
||||||
|
if images is not None and isinstance(images, str):
|
||||||
|
images = [x.strip() for x in images.split(',') if x.strip()]
|
||||||
|
try:
|
||||||
|
gd = escalate_gongdan(
|
||||||
|
gongdan_id=(d.get('gongdan_id') or '').strip(),
|
||||||
|
yonghuid=request.user.UserUID,
|
||||||
|
shuoming=d.get('shuoming') or '',
|
||||||
|
image_urls=images,
|
||||||
|
)
|
||||||
|
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '已提交补充投诉')
|
||||||
|
except GongdanError as e:
|
||||||
|
return _err(str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('升级工单失败: %s', e, exc_info=True)
|
||||||
|
return _err('提交失败', http=500)
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanDissatisfiedView(APIView):
|
||||||
|
"""标记不满意。POST /gongdan/dissatisfied/"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
gd = user_mark_dissatisfied(
|
||||||
|
gongdan_id=(request.data.get('gongdan_id') or '').strip(),
|
||||||
|
yonghuid=request.user.UserUID,
|
||||||
|
)
|
||||||
|
return _ok(serialize_gongdan(gd), '已标记不满意,可继续投诉')
|
||||||
|
except GongdanError as e:
|
||||||
|
return _err(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanMetaView(APIView):
|
||||||
|
"""类型/状态枚举,供前端展示。GET /gongdan/meta/"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return _ok({
|
||||||
|
'types': [{'value': k, 'label': v} for k, v in TYPE_LABELS.items()],
|
||||||
|
'statuses': [{'value': k, 'label': v} for k, v in STATUS_LABELS.items()],
|
||||||
|
'max_images': MAX_IMAGES,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class ChongzhiRecordListView(APIView):
|
||||||
|
"""当前用户充值记录(Czjilu),供投诉选 ID / 充值记录页。POST /gongdan/chongzhi-list/"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
from products.models import Czjilu
|
||||||
|
from products.czjilu_types import (
|
||||||
|
CZJILU_LEIXING_HUIYUAN, CZJILU_LEIXING_YAJIN, CZJILU_LEIXING_JIFEN,
|
||||||
|
CZJILU_LEIXING_SHANGJIA,
|
||||||
|
)
|
||||||
|
page = int(request.data.get('page') or 1)
|
||||||
|
page_size = min(int(request.data.get('page_size') or 20), 50)
|
||||||
|
qs = Czjilu.query.filter(yonghuid=request.user.UserUID).order_by('-CreateTime')
|
||||||
|
leixing = request.data.get('leixing')
|
||||||
|
if leixing not in (None, ''):
|
||||||
|
qs = qs.filter(leixing=int(leixing))
|
||||||
|
total = qs.count()
|
||||||
|
start = max(page - 1, 0) * page_size
|
||||||
|
type_map = {
|
||||||
|
CZJILU_LEIXING_HUIYUAN: '会员',
|
||||||
|
CZJILU_LEIXING_YAJIN: '押金',
|
||||||
|
CZJILU_LEIXING_JIFEN: '积分',
|
||||||
|
CZJILU_LEIXING_SHANGJIA: '商家充值',
|
||||||
|
5: '考核',
|
||||||
|
6: '罚款',
|
||||||
|
}
|
||||||
|
status_map = {9: '待支付', 3: '已支付', 2: '已取消', 8: '履约失败', 10: '业务失败'}
|
||||||
|
rows = []
|
||||||
|
for o in qs[start:start + page_size]:
|
||||||
|
rows.append({
|
||||||
|
'dingdan_id': o.dingdan_id,
|
||||||
|
'leixing': o.leixing,
|
||||||
|
'leixing_label': type_map.get(o.leixing, str(o.leixing)),
|
||||||
|
'jine': str(o.jine),
|
||||||
|
'zhuangtai': o.zhuangtai,
|
||||||
|
'zhuangtai_label': status_map.get(o.zhuangtai, str(o.zhuangtai)),
|
||||||
|
'shuoming': o.shuoming or '',
|
||||||
|
'CreateTime': o.CreateTime.isoformat() if getattr(o, 'CreateTime', None) else '',
|
||||||
|
})
|
||||||
|
return _ok({'list': rows, 'total': total, 'page': page, 'page_size': page_size})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- 客服后台 ----------
|
||||||
|
|
||||||
|
def _require_kefu_perm(request, *need_codes):
|
||||||
|
from backend.utils import verify_kefu_permission
|
||||||
|
username = request.data.get('username') or request.query_params.get('username') or getattr(request.user, 'Phone', None)
|
||||||
|
kefu, perms_or_resp = verify_kefu_permission(request, username)
|
||||||
|
if not kefu:
|
||||||
|
return None, None, perms_or_resp
|
||||||
|
perms = perms_or_resp if isinstance(perms_or_resp, list) else []
|
||||||
|
is_admin = (
|
||||||
|
getattr(request.user, 'UserType', '') == 'admin'
|
||||||
|
or getattr(request.user, 'IsSuperuser', False)
|
||||||
|
or '000001' in perms
|
||||||
|
)
|
||||||
|
if is_admin:
|
||||||
|
return kefu, perms, None
|
||||||
|
if not any(c in perms for c in need_codes):
|
||||||
|
return None, None, _err('无工单权限', http=403)
|
||||||
|
return kefu, perms, None
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanAdminListView(APIView):
|
||||||
|
"""客服工单列表。POST /gongdan/admin/list/"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
kefu, perms, err = _require_kefu_perm(request, PERM_VIEW, PERM_HANDLE)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
page = int(request.data.get('page') or 1)
|
||||||
|
page_size = min(int(request.data.get('page_size') or 20), 100)
|
||||||
|
from django.db.models import Q
|
||||||
|
qs = Gongdan.query.all().order_by('-CreateTime')
|
||||||
|
try:
|
||||||
|
from jituan.services.club_context import filter_club_char_field
|
||||||
|
qs = filter_club_char_field(qs, request, field='club_id')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
zhuangtai = request.data.get('zhuangtai')
|
||||||
|
leixing = request.data.get('leixing')
|
||||||
|
keyword = (request.data.get('keyword') or '').strip()
|
||||||
|
only_escalated = str(request.data.get('only_escalated') or '').lower() in ('1', 'true', 'yes')
|
||||||
|
need_handle = str(request.data.get('need_handle') or '').lower() in ('1', 'true', 'yes')
|
||||||
|
if need_handle:
|
||||||
|
qs = qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3))
|
||||||
|
elif zhuangtai not in (None, ''):
|
||||||
|
qs = qs.filter(zhuangtai=int(zhuangtai))
|
||||||
|
if only_escalated:
|
||||||
|
qs = qs.filter(shengji_cishu__gt=0).filter(Q(zhuangtai=1) | Q(zhuangtai=3))
|
||||||
|
if leixing not in (None, ''):
|
||||||
|
qs = qs.filter(leixing=int(leixing))
|
||||||
|
if keyword:
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(gongdan_id__icontains=keyword)
|
||||||
|
| Q(shenqingren_id__icontains=keyword)
|
||||||
|
| Q(order_id__icontains=keyword)
|
||||||
|
| Q(chongzhi_id__icontains=keyword)
|
||||||
|
| Q(fadan_id__icontains=keyword)
|
||||||
|
)
|
||||||
|
total = qs.count()
|
||||||
|
start = max(page - 1, 0) * page_size
|
||||||
|
rows = [serialize_gongdan(g, include_images=False) for g in qs[start:start + page_size]]
|
||||||
|
|
||||||
|
# 统计基于俱乐部范围全量(不受当前筛选影响),方便客服一眼看到待办量
|
||||||
|
stats_qs = Gongdan.query.all()
|
||||||
|
try:
|
||||||
|
from jituan.services.club_context import filter_club_char_field
|
||||||
|
stats_qs = filter_club_char_field(stats_qs, request, field='club_id')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
stats = {
|
||||||
|
'total': stats_qs.count(),
|
||||||
|
'pending': stats_qs.filter(zhuangtai=1).count(),
|
||||||
|
'done': stats_qs.filter(zhuangtai=2).count(),
|
||||||
|
'dissatisfied': stats_qs.filter(zhuangtai=3).count(),
|
||||||
|
'closed': stats_qs.filter(zhuangtai=4).count(),
|
||||||
|
'escalated': stats_qs.filter(shengji_cishu__gt=0).filter(
|
||||||
|
Q(zhuangtai=1) | Q(zhuangtai=3)
|
||||||
|
).count(),
|
||||||
|
'need_handle': stats_qs.filter(Q(zhuangtai=1) | Q(zhuangtai=3)).count(),
|
||||||
|
}
|
||||||
|
return _ok({
|
||||||
|
'list': rows,
|
||||||
|
'total': total,
|
||||||
|
'page': page,
|
||||||
|
'page_size': page_size,
|
||||||
|
'stats': stats,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class GongdanAdminHandleView(APIView):
|
||||||
|
"""客服处理工单。POST /gongdan/admin/handle/"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
kefu, perms, err = _require_kefu_perm(request, PERM_HANDLE)
|
||||||
|
if err:
|
||||||
|
return err
|
||||||
|
d = request.data
|
||||||
|
handler_uid = getattr(request.user, 'UserUID', '') or getattr(request.user, 'Phone', '')
|
||||||
|
handler_name = getattr(request.user, 'NickName', None) or getattr(request.user, 'Phone', '') or handler_uid
|
||||||
|
try:
|
||||||
|
gd = handle_gongdan(
|
||||||
|
gongdan_id=(d.get('gongdan_id') or '').strip(),
|
||||||
|
handler_uid=handler_uid,
|
||||||
|
handler_name=handler_name,
|
||||||
|
action=d.get('action') or 'done',
|
||||||
|
chuli_jieguo=d.get('chuli_jieguo') or d.get('jieguo') or '',
|
||||||
|
)
|
||||||
|
return _ok(serialize_gongdan(gd, include_images=True, include_shengji=True), '处理成功')
|
||||||
|
except GongdanError as e:
|
||||||
|
return _err(str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('处理工单失败: %s', e, exc_info=True)
|
||||||
|
return _err('处理失败', http=500)
|
||||||
@@ -28,3 +28,37 @@ ADMIN_SCOPE_CLUB = 'CLUB'
|
|||||||
|
|
||||||
# 集团最高权限账号(写死超级管理员,拥有全部功能权限与集团视图)
|
# 集团最高权限账号(写死超级管理员,拥有全部功能权限与集团视图)
|
||||||
SUPER_ADMIN_PHONES = frozenset({'1383714110'})
|
SUPER_ADMIN_PHONES = frozenset({'1383714110'})
|
||||||
|
|
||||||
|
# —— 支付通道 ——
|
||||||
|
PAY_CHANNEL_WECHAT_JSAPI = 'wechat_jsapi'
|
||||||
|
PAY_CHANNEL_WECHAT_V3 = 'wechat_v3'
|
||||||
|
PAY_CHANNEL_ALIPAY_WAP = 'alipay_wap'
|
||||||
|
PAY_CHANNEL_FUBEI_WX_MINI = 'fubei_wx_mini'
|
||||||
|
|
||||||
|
# 俱乐部「小程序收款」选用哪条通道(前端仍传 pay_method=wechat,后端按此路由)
|
||||||
|
MINI_PAY_CHANNEL_WECHAT = 'wechat'
|
||||||
|
MINI_PAY_CHANNEL_FUBEI = 'fubei'
|
||||||
|
MINI_PAY_CHANNEL_CHOICES = (MINI_PAY_CHANNEL_WECHAT, MINI_PAY_CHANNEL_FUBEI)
|
||||||
|
|
||||||
|
PAY_METHOD_WECHAT = 'wechat'
|
||||||
|
PAY_METHOD_ALIPAY = 'alipay'
|
||||||
|
PAY_METHOD_FUBEI = 'fubei'
|
||||||
|
|
||||||
|
PAY_METHOD_LABELS = {
|
||||||
|
PAY_METHOD_WECHAT: '微信支付',
|
||||||
|
PAY_METHOD_ALIPAY: '支付宝',
|
||||||
|
PAY_METHOD_FUBEI: '微信支付', # 前端不感知,展示仍为微信
|
||||||
|
}
|
||||||
|
|
||||||
|
PAY_METHOD_TO_CHANNEL = {
|
||||||
|
PAY_METHOD_WECHAT: PAY_CHANNEL_WECHAT_JSAPI,
|
||||||
|
PAY_METHOD_ALIPAY: PAY_CHANNEL_ALIPAY_WAP,
|
||||||
|
PAY_METHOD_FUBEI: PAY_CHANNEL_FUBEI_WX_MINI,
|
||||||
|
}
|
||||||
|
|
||||||
|
FUBEI_GATEWAY_PROD = 'https://shq-api.51fubei.com/gateway'
|
||||||
|
FUBEI_GATEWAY_SANDBOX = 'https://shq-api.51fubei.com/gateway' # 付呗文档未单独给沙箱网关时同正式
|
||||||
|
|
||||||
|
# 付呗下单 biz 标记(写入 attach / 流水表,回调与退款用)
|
||||||
|
FUBEI_BIZ_ORDER = 'order'
|
||||||
|
FUBEI_BIZ_CZJILU = 'czjilu'
|
||||||
|
|||||||
1
jituan/data/README.md
Normal file
1
jituan/data/README.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# 内置数据目录:UFO 商品导入 catalog 等
|
||||||
4046
jituan/data/ufo_catalog.json
Normal file
4046
jituan/data/ufo_catalog.json
Normal file
File diff suppressed because it is too large
Load Diff
210
jituan/management/commands/cleanup_lsx_empty_club.py
Normal file
210
jituan/management/commands/cleanup_lsx_empty_club.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""清理龙先生重复/无 pem 证书俱乐部。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 候选:club_id=lsx / 名称含「龙先生」 / wx_appid=龙先生小程序
|
||||||
|
- 「有 pem」:pay_key_path / pay_cert_path / private_key_path 任一路径以 .pem 结尾
|
||||||
|
(你后台上传的证书路径都是 *.pem;脚本自动建的/空配置通常没有)
|
||||||
|
- 「可删」:上述三个字段都没有以 .pem 结尾的路径
|
||||||
|
- 默认 dry-run;--apply 才删除
|
||||||
|
- 若候选里没有任何「有 pem」的对照,默认不删;加 --force-delete-empty-only 才删
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python manage.py cleanup_lsx_empty_club
|
||||||
|
python manage.py cleanup_lsx_empty_club --apply
|
||||||
|
python manage.py cleanup_lsx_empty_club --apply --remove-seed-users
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from jituan.models import (
|
||||||
|
Club,
|
||||||
|
ClubDashouExamConfig,
|
||||||
|
ClubDashouExamPass,
|
||||||
|
ClubDashouExamQuestion,
|
||||||
|
ClubDashouExamQuestionImage,
|
||||||
|
ClubFadanFenhongLilv,
|
||||||
|
ClubHuiyuanBundleInclude,
|
||||||
|
ClubHuiyuanPrice,
|
||||||
|
ClubKaoheChenghaoConfig,
|
||||||
|
ClubLilubiao,
|
||||||
|
ClubPaymentChannel,
|
||||||
|
ClubShangpinLeixingConfig,
|
||||||
|
ClubTixianQuota,
|
||||||
|
ClubWithdrawConfig,
|
||||||
|
)
|
||||||
|
from users.business_models import User
|
||||||
|
from users.models import UserBoss, UserGuanshi, UserZuzhang
|
||||||
|
|
||||||
|
LSX_APPID = 'wx7ff90e9d024fcdb8'
|
||||||
|
SEED_UIDS = ('9910001', '9910002')
|
||||||
|
|
||||||
|
RELATED_MODELS = (
|
||||||
|
ClubPaymentChannel,
|
||||||
|
ClubHuiyuanPrice,
|
||||||
|
ClubHuiyuanBundleInclude,
|
||||||
|
ClubLilubiao,
|
||||||
|
ClubWithdrawConfig,
|
||||||
|
ClubTixianQuota,
|
||||||
|
ClubShangpinLeixingConfig,
|
||||||
|
ClubKaoheChenghaoConfig,
|
||||||
|
ClubFadanFenhongLilv,
|
||||||
|
ClubDashouExamConfig,
|
||||||
|
ClubDashouExamQuestion,
|
||||||
|
ClubDashouExamQuestionImage,
|
||||||
|
ClubDashouExamPass,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 证书「文件」字段(应以 .pem 结尾);platform_cert_dir 是目录,不参与判定
|
||||||
|
_PEM_FILE_FIELDS = ('pay_key_path', 'pay_cert_path', 'private_key_path')
|
||||||
|
|
||||||
|
|
||||||
|
def _path_ends_with_pem(path):
|
||||||
|
return str(path or '').strip().lower().endswith('.pem')
|
||||||
|
|
||||||
|
|
||||||
|
def _has_pem_paths(club):
|
||||||
|
"""后台上传过证书:路径以 .pem 结尾。"""
|
||||||
|
return any(_path_ends_with_pem(getattr(club, f, '')) for f in _PEM_FILE_FIELDS)
|
||||||
|
|
||||||
|
|
||||||
|
def _pem_field_summary(club):
|
||||||
|
lines = []
|
||||||
|
for f in _PEM_FILE_FIELDS:
|
||||||
|
val = (getattr(club, f, '') or '').strip()
|
||||||
|
if not val:
|
||||||
|
tag = '空'
|
||||||
|
elif _path_ends_with_pem(val):
|
||||||
|
tag = 'pem✓'
|
||||||
|
else:
|
||||||
|
tag = '非pem✗'
|
||||||
|
lines.append(f'{f}={val or "(空)"} [{tag}]')
|
||||||
|
plat = (getattr(club, 'platform_cert_dir', '') or '').strip()
|
||||||
|
lines.append(f'platform_cert_dir={plat or "(空)"} [目录仅展示]')
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates():
|
||||||
|
return Club.query.filter(
|
||||||
|
Q(club_id__iexact='lsx')
|
||||||
|
| Q(club_id__icontains='lsx')
|
||||||
|
| Q(name__icontains='龙先生')
|
||||||
|
| Q(wx_appid=LSX_APPID)
|
||||||
|
).order_by('club_id')
|
||||||
|
|
||||||
|
|
||||||
|
def _purge_club_related(club_id, stdout, style):
|
||||||
|
for model in RELATED_MODELS:
|
||||||
|
try:
|
||||||
|
n = model.query.filter(club_id=club_id).count()
|
||||||
|
if n:
|
||||||
|
model.query.filter(club_id=club_id).delete()
|
||||||
|
stdout.write(f' - 已删 {model.__name__} x{n}')
|
||||||
|
except Exception as exc:
|
||||||
|
stdout.write(style.WARNING(f' - 跳过 {model.__name__}: {exc}'))
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '列出并删除龙先生相关「证书路径不以 .pem 结尾」的重复俱乐部(默认 dry-run)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--apply', action='store_true', help='真正写入删除')
|
||||||
|
parser.add_argument(
|
||||||
|
'--force-delete-empty-only',
|
||||||
|
action='store_true',
|
||||||
|
help='即使没有「有 .pem」的对照俱乐部,也允许删除非 pem 候选',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--remove-seed-users',
|
||||||
|
action='store_true',
|
||||||
|
help='同时删除 seed_lsx_invite_data 种子用户 9910001/9910002',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--club-id',
|
||||||
|
type=str,
|
||||||
|
default='',
|
||||||
|
help='只处理指定 club_id(精确匹配)',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
apply = bool(options.get('apply'))
|
||||||
|
force = bool(options.get('force_delete_empty_only'))
|
||||||
|
only_id = (options.get('club_id') or '').strip()
|
||||||
|
|
||||||
|
qs = _candidates()
|
||||||
|
if only_id:
|
||||||
|
qs = Club.query.filter(club_id=only_id)
|
||||||
|
|
||||||
|
clubs = list(qs)
|
||||||
|
if not clubs:
|
||||||
|
self.stdout.write(self.style.WARNING('未找到龙先生/lsx 相关俱乐部'))
|
||||||
|
return
|
||||||
|
|
||||||
|
with_pem = []
|
||||||
|
without_pem = []
|
||||||
|
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||||
|
self.stdout.write('判定标准:pay_key_path / pay_cert_path / private_key_path 是否以 .pem 结尾')
|
||||||
|
self.stdout.write('龙先生相关俱乐部扫描结果:')
|
||||||
|
for c in clubs:
|
||||||
|
keyed = _has_pem_paths(c)
|
||||||
|
(with_pem if keyed else without_pem).append(c)
|
||||||
|
self.stdout.write('-' * 40)
|
||||||
|
self.stdout.write(f'club_id={c.club_id} name={c.name} status={c.status}')
|
||||||
|
self.stdout.write(f' wx_appid={c.wx_appid or "(空)"}')
|
||||||
|
for line in _pem_field_summary(c):
|
||||||
|
self.stdout.write(f' {line}')
|
||||||
|
self.stdout.write(
|
||||||
|
f' >>> {"有 .pem 路径(保留)" if keyed else "无 .pem 路径(可删)"}'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||||
|
self.stdout.write(f'有 .pem: {len(with_pem)} 条;无 .pem: {len(without_pem)} 条')
|
||||||
|
|
||||||
|
if not without_pem:
|
||||||
|
self.stdout.write(self.style.SUCCESS('没有「无 .pem 路径」的俱乐部,无需删除。'))
|
||||||
|
elif not with_pem and not force:
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
'全部候选都没有 .pem 路径。为安全起见不自动删。'
|
||||||
|
'若确认要删,加 --force-delete-empty-only --apply'
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
for c in without_pem:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
f'将删除无 .pem 俱乐部: {c.club_id} ({c.name})'
|
||||||
|
))
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(' (dry-run,未真正删除;加 --apply 执行)')
|
||||||
|
continue
|
||||||
|
with transaction.atomic():
|
||||||
|
_purge_club_related(c.club_id, self.stdout, self.style)
|
||||||
|
Club.query.filter(club_id=c.club_id).delete()
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' 已删除俱乐部 {c.club_id}'))
|
||||||
|
|
||||||
|
if options.get('remove_seed_users'):
|
||||||
|
self.stdout.write('-' * 40)
|
||||||
|
self.stdout.write('处理种子用户 9910001 / 9910002 ...')
|
||||||
|
for uid in SEED_UIDS:
|
||||||
|
user = User.query.filter(UserUID=uid).first()
|
||||||
|
if not user:
|
||||||
|
self.stdout.write(f' {uid}: 不存在')
|
||||||
|
continue
|
||||||
|
self.stdout.write(
|
||||||
|
f' {uid}: ClubID={user.ClubID} Phone={user.Phone} OpenID={user.OpenID}'
|
||||||
|
)
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(' (dry-run)')
|
||||||
|
continue
|
||||||
|
with transaction.atomic():
|
||||||
|
UserZuzhang.query.filter(user=user).delete()
|
||||||
|
UserGuanshi.query.filter(user=user).delete()
|
||||||
|
UserBoss.query.filter(user=user).delete()
|
||||||
|
user.delete()
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' 已删除用户 {uid}'))
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'\n当前为预览。确认后执行:\n'
|
||||||
|
' python manage.py cleanup_lsx_empty_club --apply\n'
|
||||||
|
'如需顺带删种子账号:\n'
|
||||||
|
' python manage.py cleanup_lsx_empty_club --apply --remove-seed-users'
|
||||||
|
))
|
||||||
387
jituan/management/commands/copy_lxs_shenhe_catalog_to_xx.py
Normal file
387
jituan/management/commands/copy_lxs_shenhe_catalog_to_xx.py
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
把龙先生(lxs)审核版商品目录复制到小溪(xx)。
|
||||||
|
|
||||||
|
范围(必须全部满足):
|
||||||
|
- 源俱乐部固定 lxs(name 含「龙先生」)
|
||||||
|
- 目标俱乐部固定 xx(name 含「小溪」「小西」或「小汐」)
|
||||||
|
- 仅全局 ShangpinLeixing.shenhezhuangtai=2 的类型
|
||||||
|
- 仅公共目录 dianpu IS NULL
|
||||||
|
- 专区/商品均要求 shenhezhuangtai=2
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
1) (可选)清空 xx 下上述审核类型的专区+商品(只删 DB,不删 OSS)
|
||||||
|
2) 从 lxs 复制同类型专区+商品到 xx(图片复用相对路径;销量归零)
|
||||||
|
3) 确保 xx 的 ClubShangpinLeixingConfig 启用这些审核类型(图标可复用源配置)
|
||||||
|
|
||||||
|
不碰:正常商品(shenhezhuangtai=1)、其它俱乐部、店铺商品、全局类型表、订单等。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python manage.py copy_lxs_shenhe_catalog_to_xx --list
|
||||||
|
python manage.py copy_lxs_shenhe_catalog_to_xx
|
||||||
|
python manage.py copy_lxs_shenhe_catalog_to_xx --apply --i-confirm=lxs-to-xx
|
||||||
|
python manage.py copy_lxs_shenhe_catalog_to_xx --apply --i-confirm=lxs-to-xx --clear-target
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
SOURCE_CLUB = 'lxs'
|
||||||
|
SOURCE_NAME_HINT = '龙先生'
|
||||||
|
TARGET_CLUB = 'xx'
|
||||||
|
TARGET_NAME_HINTS = ('小溪', '小西', '小汐')
|
||||||
|
SHENHE = 2
|
||||||
|
CONFIRM_TOKEN = 'lxs-to-xx'
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '复制龙先生审核专区/商品到小溪(xx)(默认 dry-run)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--apply', action='store_true', help='真正写库')
|
||||||
|
parser.add_argument(
|
||||||
|
'--i-confirm',
|
||||||
|
type=str,
|
||||||
|
default='',
|
||||||
|
help=f'必须等于 {CONFIRM_TOKEN} 才允许 --apply',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--clear-target',
|
||||||
|
action='store_true',
|
||||||
|
help='写入前先清空 xx 下对应审核专区/商品(只删 DB)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--list',
|
||||||
|
action='store_true',
|
||||||
|
help='只列出审核类型及 lxs/xx 下专区商品数量',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from jituan.models import Club, ClubShangpinLeixingConfig
|
||||||
|
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||||
|
|
||||||
|
apply = bool(options.get('apply'))
|
||||||
|
confirm = (options.get('i_confirm') or '').strip()
|
||||||
|
clear_target = bool(options.get('clear_target'))
|
||||||
|
|
||||||
|
src_club = Club.query.filter(club_id=SOURCE_CLUB).first()
|
||||||
|
if not src_club:
|
||||||
|
raise CommandError(f'源俱乐部 {SOURCE_CLUB} 不存在')
|
||||||
|
if SOURCE_NAME_HINT not in (src_club.name or ''):
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:{SOURCE_CLUB} 的 name={src_club.name!r} 不含「{SOURCE_NAME_HINT}」'
|
||||||
|
)
|
||||||
|
|
||||||
|
dst_club = Club.query.filter(club_id=TARGET_CLUB).first()
|
||||||
|
if not dst_club:
|
||||||
|
raise CommandError(
|
||||||
|
f'目标俱乐部 {TARGET_CLUB} 不存在。请先在后台创建小溪俱乐部(club_id=xx)。'
|
||||||
|
)
|
||||||
|
if not any(h in (dst_club.name or '') for h in TARGET_NAME_HINTS):
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:{TARGET_CLUB} 的 name={dst_club.name!r} '
|
||||||
|
f'不含「{"/".join(TARGET_NAME_HINTS)}」'
|
||||||
|
)
|
||||||
|
|
||||||
|
audit_types = list(
|
||||||
|
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
|
||||||
|
)
|
||||||
|
if not audit_types:
|
||||||
|
raise CommandError('没有任何审核专用商品类型(shenhezhuangtai=2)')
|
||||||
|
if len(audit_types) != 3:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
f'注意:当前审核类型数量={len(audit_types)}(预期常为 3),将按实际全部处理。'
|
||||||
|
))
|
||||||
|
|
||||||
|
leixing_ids = [lx.id for lx in audit_types]
|
||||||
|
|
||||||
|
def counts(club_id):
|
||||||
|
z = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=club_id,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
p = Shangpin.query.filter(
|
||||||
|
club_id=club_id,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
return z, p
|
||||||
|
|
||||||
|
def per_type(club_id):
|
||||||
|
rows = []
|
||||||
|
for lx in audit_types:
|
||||||
|
z = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=club_id,
|
||||||
|
leixing_id=lx.id,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
p = Shangpin.query.filter(
|
||||||
|
club_id=club_id,
|
||||||
|
leixing_id=lx.id,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
rows.append((lx, z, p))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
if options.get('list'):
|
||||||
|
self.stdout.write(self.style.NOTICE('审核类型及两侧存量:'))
|
||||||
|
for lx in audit_types:
|
||||||
|
sz, sp = (
|
||||||
|
ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=SOURCE_CLUB, leixing_id=lx.id,
|
||||||
|
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||||
|
).count(),
|
||||||
|
Shangpin.query.filter(
|
||||||
|
club_id=SOURCE_CLUB, leixing_id=lx.id,
|
||||||
|
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
dz, dp = (
|
||||||
|
ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=TARGET_CLUB, leixing_id=lx.id,
|
||||||
|
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||||
|
).count(),
|
||||||
|
Shangpin.query.filter(
|
||||||
|
club_id=TARGET_CLUB, leixing_id=lx.id,
|
||||||
|
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f' id={lx.id}「{lx.jieshao}」 '
|
||||||
|
f'lxs: zones={sz} products={sp} | '
|
||||||
|
f'xx: zones={dz} products={dp}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if apply and confirm != CONFIRM_TOKEN:
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:--apply 必须带 --i-confirm={CONFIRM_TOKEN}\n'
|
||||||
|
f' python manage.py copy_lxs_shenhe_catalog_to_xx '
|
||||||
|
f'--apply --i-confirm={CONFIRM_TOKEN}'
|
||||||
|
)
|
||||||
|
|
||||||
|
src_z_total, src_p_total = counts(SOURCE_CLUB)
|
||||||
|
dst_z_total, dst_p_total = counts(TARGET_CLUB)
|
||||||
|
|
||||||
|
src_zones = list(
|
||||||
|
ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=SOURCE_CLUB,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).order_by('leixing_id', 'paixu', 'id')
|
||||||
|
)
|
||||||
|
src_products = list(
|
||||||
|
Shangpin.query.filter(
|
||||||
|
club_id=SOURCE_CLUB,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).order_by('leixing_id', 'paixu', 'id')
|
||||||
|
)
|
||||||
|
|
||||||
|
src_zone_ids = {z.id for z in src_zones}
|
||||||
|
orphan = [p for p in src_products if p.zhuanqu_id not in src_zone_ids]
|
||||||
|
if orphan:
|
||||||
|
sample = ', '.join(f'{p.id}:{p.biaoqian}' for p in orphan[:5])
|
||||||
|
raise CommandError(
|
||||||
|
f'源商品有 {len(orphan)} 条专区不在本批审核专区内,已中止。样例: {sample}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if src_z_total == 0 and src_p_total == 0:
|
||||||
|
raise CommandError('龙先生(lxs)下没有可复制的审核专区/商品')
|
||||||
|
|
||||||
|
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
f'\n===== {mode} 小溪审核目录 ← 龙先生 =====\n'
|
||||||
|
f'源: {SOURCE_CLUB} ({src_club.name}) '
|
||||||
|
f'专区={src_z_total} 商品={src_p_total}\n'
|
||||||
|
f'目标: {TARGET_CLUB} ({dst_club.name}) '
|
||||||
|
f'现有 专区={dst_z_total} 商品={dst_p_total}\n'
|
||||||
|
f'clear_target={clear_target}\n'
|
||||||
|
f'审核类型数={len(audit_types)} ids={leixing_ids}\n'
|
||||||
|
f'OSS: 不删文件;复制时复用龙先生相对路径(同一 COS 域名拼接)\n'
|
||||||
|
))
|
||||||
|
|
||||||
|
self.stdout.write('—— 分类型存量 ——')
|
||||||
|
for lx, sz, sp in per_type(SOURCE_CLUB):
|
||||||
|
self.stdout.write(f' [lxs] id={lx.id}「{lx.jieshao}」 zones={sz} products={sp}')
|
||||||
|
for lx, dz, dp in per_type(TARGET_CLUB):
|
||||||
|
self.stdout.write(f' [xx] id={lx.id}「{lx.jieshao}」 zones={dz} products={dp}')
|
||||||
|
|
||||||
|
self.stdout.write('—— 将从 lxs 复制的专区样例 ——')
|
||||||
|
for z in src_zones[:40]:
|
||||||
|
self.stdout.write(
|
||||||
|
f' zq#{z.id} leixing={z.leixing_id} {z.mingzi!r}'
|
||||||
|
)
|
||||||
|
if len(src_zones) > 40:
|
||||||
|
self.stdout.write(f' ... 其余 {len(src_zones) - 40} 个省略')
|
||||||
|
|
||||||
|
self.stdout.write('—— 将从 lxs 复制的商品样例 ——')
|
||||||
|
for p in src_products[:15]:
|
||||||
|
self.stdout.write(
|
||||||
|
f' sp#{p.id} leixing={p.leixing_id} {p.biaoqian!r} ¥{p.jiage}'
|
||||||
|
)
|
||||||
|
if len(src_products) > 15:
|
||||||
|
self.stdout.write(f' ... 其余 {len(src_products) - 15} 个省略')
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'\n以上仅计划:未删库、未写库。确认无误后:\n'
|
||||||
|
f' python manage.py copy_lxs_shenhe_catalog_to_xx '
|
||||||
|
f'--apply --i-confirm={CONFIRM_TOKEN}\n'
|
||||||
|
f'若目标已有旧审核目录需先清空,再加 --clear-target\n'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'deleted_products': 0,
|
||||||
|
'deleted_zones': 0,
|
||||||
|
'zones_created': 0,
|
||||||
|
'products_created': 0,
|
||||||
|
'cfg_created': 0,
|
||||||
|
'cfg_enabled': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
if clear_target:
|
||||||
|
n_prod, _ = Shangpin.query.filter(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).delete()
|
||||||
|
n_zone, _ = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).delete()
|
||||||
|
stats['deleted_products'] = n_prod
|
||||||
|
stats['deleted_zones'] = n_zone
|
||||||
|
|
||||||
|
zone_id_map = {}
|
||||||
|
for z in src_zones:
|
||||||
|
if z.leixing_id not in leixing_ids:
|
||||||
|
raise CommandError(f'专区 {z.id} 类型 {z.leixing_id} 不在审核列表')
|
||||||
|
# 未 clear 时若同名专区已存在则复用,避免重复堆叠
|
||||||
|
exist = None
|
||||||
|
if not clear_target:
|
||||||
|
exist = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
leixing_id=z.leixing_id,
|
||||||
|
mingzi=z.mingzi,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist:
|
||||||
|
zone_id_map[z.id] = exist.id
|
||||||
|
continue
|
||||||
|
row = ShangpinZhuanqu.query.create(
|
||||||
|
mingzi=z.mingzi,
|
||||||
|
leixing_id=z.leixing_id,
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
tupian_url=z.tupian_url or '',
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
paixu=int(z.paixu or 0),
|
||||||
|
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
|
||||||
|
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
zone_id_map[z.id] = row.id
|
||||||
|
stats['zones_created'] += 1
|
||||||
|
|
||||||
|
for p in src_products:
|
||||||
|
if p.leixing_id not in leixing_ids:
|
||||||
|
raise CommandError(f'商品 {p.id} 类型 {p.leixing_id} 不在审核列表')
|
||||||
|
dst_zq = zone_id_map.get(p.zhuanqu_id)
|
||||||
|
if not dst_zq:
|
||||||
|
raise CommandError(
|
||||||
|
f'商品「{p.biaoqian}」id={p.id} 无法映射 zhuanqu_id={p.zhuanqu_id}'
|
||||||
|
)
|
||||||
|
if not clear_target:
|
||||||
|
exist_p = Shangpin.query.filter(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
leixing_id=p.leixing_id,
|
||||||
|
zhuanqu_id=dst_zq,
|
||||||
|
biaoqian=p.biaoqian,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist_p:
|
||||||
|
continue
|
||||||
|
Shangpin.query.create(
|
||||||
|
biaoqian=p.biaoqian,
|
||||||
|
jiage=p.jiage,
|
||||||
|
kucun=p.kucun,
|
||||||
|
leixing_id=p.leixing_id,
|
||||||
|
zhuanqu_id=dst_zq,
|
||||||
|
zhenshi_xiaoliang=0,
|
||||||
|
duiwai_xiaoliang=0,
|
||||||
|
jieshao=p.jieshao,
|
||||||
|
xiadan_xuzhi=p.xiadan_xuzhi,
|
||||||
|
guize_tupian=p.guize_tupian or '',
|
||||||
|
tupian_url=p.tupian_url or '',
|
||||||
|
yaoqiuleixing=p.yaoqiuleixing,
|
||||||
|
huiyuan_id=p.huiyuan_id,
|
||||||
|
yongjin=p.yongjin,
|
||||||
|
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
|
||||||
|
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
|
||||||
|
paixu=int(p.paixu or 0),
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
shangjia_zhuangtai=bool(p.shangjia_zhuangtai),
|
||||||
|
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
|
||||||
|
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', True)),
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
shi_zhuanpan=False,
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
stats['products_created'] += 1
|
||||||
|
|
||||||
|
# 启用审核类型上架配置
|
||||||
|
for lx in audit_types:
|
||||||
|
src_cfg = ClubShangpinLeixingConfig.query.filter(
|
||||||
|
club_id=SOURCE_CLUB, leixing_id=lx.id,
|
||||||
|
).first()
|
||||||
|
dst_cfg = ClubShangpinLeixingConfig.query.filter(
|
||||||
|
club_id=TARGET_CLUB, leixing_id=lx.id,
|
||||||
|
).first()
|
||||||
|
icon = ''
|
||||||
|
paixu = int(getattr(lx, 'paixu', 0) or 0)
|
||||||
|
if src_cfg:
|
||||||
|
icon = src_cfg.tupian_url or ''
|
||||||
|
paixu = int(src_cfg.paixu or paixu)
|
||||||
|
if dst_cfg is None:
|
||||||
|
ClubShangpinLeixingConfig.query.create(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
leixing_id=lx.id,
|
||||||
|
is_enabled=True,
|
||||||
|
tupian_url=icon,
|
||||||
|
paixu=paixu,
|
||||||
|
)
|
||||||
|
stats['cfg_created'] += 1
|
||||||
|
else:
|
||||||
|
changed = False
|
||||||
|
if not dst_cfg.is_enabled:
|
||||||
|
dst_cfg.is_enabled = True
|
||||||
|
changed = True
|
||||||
|
if icon and not (dst_cfg.tupian_url or '').strip():
|
||||||
|
dst_cfg.tupian_url = icon
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
dst_cfg.save()
|
||||||
|
stats['cfg_enabled'] += 1
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'\n完成: {stats}\n'
|
||||||
|
f'龙先生(lxs)未改动;小溪(xx)已具备审核目录。\n'
|
||||||
|
f'请用小溪小程序(CLUB_ID=xx)重新编译验证。'
|
||||||
|
))
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
将龙先生(lxs)审核类型「娱乐护航」下的专区+商品,复制到其它审核专用商品类型。
|
||||||
|
|
||||||
|
背景:全局通常有 3 个 shenhezhuangtai=2 的商品类型;其中「娱乐护航」已有专区/商品,
|
||||||
|
另外两个往往为空。本命令只增不减,把源类型目录镜像到目标类型。
|
||||||
|
|
||||||
|
范围:
|
||||||
|
- 仅 club_id=lxs(Club.name 须含「龙先生」)
|
||||||
|
- 仅 shenhezhuangtai=2(审核专用)
|
||||||
|
- 仅公共目录 dianpu IS NULL
|
||||||
|
- 源类型:名称精确「娱乐护航」(可用 --source-name 覆盖)
|
||||||
|
- 目标:除源以外的全部审核类型(或 --target-id 指定)
|
||||||
|
|
||||||
|
不碰:正常类型/商品、其它俱乐部、店铺商品、源数据、全局类型表本身。
|
||||||
|
|
||||||
|
图片:复用源相对路径(同俱乐部审核素材,不另拷 OSS)。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python manage.py copy_lxs_shenhe_huhang_to_other_types
|
||||||
|
python manage.py copy_lxs_shenhe_huhang_to_other_types --list-leixing
|
||||||
|
python manage.py copy_lxs_shenhe_huhang_to_other_types --apply --i-confirm-club=lxs
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
ALLOWED_CLUB_ID = 'lxs'
|
||||||
|
NAME_HINT = '龙先生'
|
||||||
|
SHENHE = 2
|
||||||
|
DEFAULT_SOURCE_NAME = '娱乐护航'
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '龙先生:把审核类型「娱乐护航」的专区/商品复制到其它审核类型(默认 dry-run)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--apply', action='store_true', help='真正写库')
|
||||||
|
parser.add_argument(
|
||||||
|
'--i-confirm-club',
|
||||||
|
type=str,
|
||||||
|
default='',
|
||||||
|
help='必须等于 lxs 才允许 --apply',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--club-id',
|
||||||
|
type=str,
|
||||||
|
default=ALLOWED_CLUB_ID,
|
||||||
|
help='仅允许 lxs',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--source-name',
|
||||||
|
type=str,
|
||||||
|
default=DEFAULT_SOURCE_NAME,
|
||||||
|
help=f'源审核类型全名精确匹配,默认「{DEFAULT_SOURCE_NAME}」',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--source-id',
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help='可选:直接指定源 ShangpinLeixing.id(须为审核专用且名称校验)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--target-id',
|
||||||
|
type=int,
|
||||||
|
action='append',
|
||||||
|
default=[],
|
||||||
|
help='可选:只复制到指定审核类型 id,可多次传入;默认=除源外全部审核类型',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--list-leixing',
|
||||||
|
action='store_true',
|
||||||
|
help='只列出审核类型及 lxs 下专区/商品数量',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from jituan.models import Club
|
||||||
|
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||||
|
|
||||||
|
club_id = (options.get('club_id') or '').strip()
|
||||||
|
apply = bool(options.get('apply'))
|
||||||
|
confirm = (options.get('i_confirm_club') or '').strip()
|
||||||
|
source_name = (options.get('source_name') or DEFAULT_SOURCE_NAME).strip()
|
||||||
|
|
||||||
|
if club_id != ALLOWED_CLUB_ID:
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:只允许 club_id={ALLOWED_CLUB_ID!r},你传的是 {club_id!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||||
|
if not club:
|
||||||
|
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
|
||||||
|
if NAME_HINT not in (club.name or ''):
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:{ALLOWED_CLUB_ID} 的 name={club.name!r} 不含「{NAME_HINT}」'
|
||||||
|
)
|
||||||
|
|
||||||
|
audit_types = list(
|
||||||
|
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
|
||||||
|
)
|
||||||
|
if not audit_types:
|
||||||
|
raise CommandError('库中没有任何审核专用商品类型(shenhezhuangtai=2)')
|
||||||
|
|
||||||
|
def count_for(leixing_id):
|
||||||
|
z = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id=leixing_id,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
p = Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id=leixing_id,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
return z, p
|
||||||
|
|
||||||
|
if options.get('list_leixing'):
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
f'审核类型清单(club={ALLOWED_CLUB_ID} 下专区/商品数):'
|
||||||
|
))
|
||||||
|
for lx in audit_types:
|
||||||
|
zc, pc = count_for(lx.id)
|
||||||
|
mark = ' <<-- 源候选' if (lx.jieshao or '') == source_name else ''
|
||||||
|
self.stdout.write(
|
||||||
|
f' id={lx.id} name={lx.jieshao!r} zones={zc} products={pc}{mark}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if apply and confirm != ALLOWED_CLUB_ID:
|
||||||
|
raise CommandError(
|
||||||
|
'安全拦截:--apply 必须带 --i-confirm-club=lxs\n'
|
||||||
|
' python manage.py copy_lxs_shenhe_huhang_to_other_types '
|
||||||
|
'--apply --i-confirm-club=lxs'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解析源类型
|
||||||
|
source_id = int(options.get('source_id') or 0)
|
||||||
|
if source_id:
|
||||||
|
source = ShangpinLeixing.query.filter(id=source_id).first()
|
||||||
|
if not source:
|
||||||
|
raise CommandError(f'找不到类型 id={source_id}')
|
||||||
|
if source.shenhezhuangtai != SHENHE:
|
||||||
|
raise CommandError(f'类型 id={source_id} 不是审核专用')
|
||||||
|
if (source.jieshao or '') != source_name:
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:id={source_id} 名称是「{source.jieshao}」,'
|
||||||
|
f'与要求的「{source_name}」不一致'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
matches = [lx for lx in audit_types if (lx.jieshao or '') == source_name]
|
||||||
|
if not matches:
|
||||||
|
# 宽松:名称包含「娱乐护」且含「航」或等于常见别名
|
||||||
|
soft = [
|
||||||
|
lx for lx in audit_types
|
||||||
|
if '娱乐护' in (lx.jieshao or '')
|
||||||
|
]
|
||||||
|
hint = '\n'.join(
|
||||||
|
f' id={lx.id} name={lx.jieshao!r}' for lx in audit_types
|
||||||
|
)
|
||||||
|
raise CommandError(
|
||||||
|
f'找不到审核类型全名「{source_name}」。当前审核类型:\n{hint}\n'
|
||||||
|
f'可用 --source-id=... 或 --source-name=... 指定。'
|
||||||
|
+ (f'\n名称含「娱乐护」的有: {[s.jieshao for s in soft]}' if soft else '')
|
||||||
|
)
|
||||||
|
if len(matches) > 1:
|
||||||
|
raise CommandError(
|
||||||
|
f'「{source_name}」匹配到多条 {[m.id for m in matches]},请 --source-id='
|
||||||
|
)
|
||||||
|
source = matches[0]
|
||||||
|
|
||||||
|
src_zones_n, src_prods_n = count_for(source.id)
|
||||||
|
if src_zones_n == 0 and src_prods_n == 0:
|
||||||
|
raise CommandError(
|
||||||
|
f'源类型 id={source.id}「{source.jieshao}」在 {ALLOWED_CLUB_ID} 下'
|
||||||
|
f'没有任何审核专区/商品,无需复制'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 目标类型
|
||||||
|
target_ids = [int(x) for x in (options.get('target_id') or [])]
|
||||||
|
if target_ids:
|
||||||
|
targets = []
|
||||||
|
for tid in target_ids:
|
||||||
|
lx = ShangpinLeixing.query.filter(id=tid).first()
|
||||||
|
if not lx:
|
||||||
|
raise CommandError(f'目标类型 id={tid} 不存在')
|
||||||
|
if lx.shenhezhuangtai != SHENHE:
|
||||||
|
raise CommandError(f'目标类型 id={tid} 不是审核专用,拒绝')
|
||||||
|
if lx.id == source.id:
|
||||||
|
raise CommandError('目标不能与源相同')
|
||||||
|
targets.append(lx)
|
||||||
|
else:
|
||||||
|
targets = [lx for lx in audit_types if lx.id != source.id]
|
||||||
|
|
||||||
|
if not targets:
|
||||||
|
raise CommandError('没有可复制的目标审核类型(除源外为空)')
|
||||||
|
|
||||||
|
src_zones = list(
|
||||||
|
ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id=source.id,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).order_by('paixu', 'id')
|
||||||
|
)
|
||||||
|
src_products = list(
|
||||||
|
Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id=source.id,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).order_by('paixu', 'id')
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
f'\n===== {mode} 复制审核目录 =====\n'
|
||||||
|
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
|
||||||
|
f'源类型: id={source.id}「{source.jieshao}」 '
|
||||||
|
f'专区={len(src_zones)} 商品={len(src_products)}\n'
|
||||||
|
f'目标类型({len(targets)}): '
|
||||||
|
+ ', '.join(f'id={t.id}「{t.jieshao}」' for t in targets)
|
||||||
|
+ '\n'
|
||||||
|
))
|
||||||
|
|
||||||
|
for t in targets:
|
||||||
|
zc, pc = count_for(t.id)
|
||||||
|
self.stdout.write(f' 目标当前存量 id={t.id}「{t.jieshao}」 zones={zc} products={pc}')
|
||||||
|
|
||||||
|
self.stdout.write('—— 源专区 ——')
|
||||||
|
for z in src_zones:
|
||||||
|
self.stdout.write(f' id={z.id} {z.mingzi!r} paixu={z.paixu}')
|
||||||
|
self.stdout.write('—— 源商品样例 ——')
|
||||||
|
for p in src_products[:8]:
|
||||||
|
self.stdout.write(f' id={p.id} {p.biaoqian!r} ¥{p.jiage} zq={p.zhuanqu_id}')
|
||||||
|
if len(src_products) > 8:
|
||||||
|
self.stdout.write(f' ... 其余 {len(src_products) - 8} 个省略')
|
||||||
|
|
||||||
|
# 预计算每个目标的 create/skip
|
||||||
|
preview = []
|
||||||
|
for t in targets:
|
||||||
|
z_create = z_skip = p_create = p_skip = 0
|
||||||
|
for z in src_zones:
|
||||||
|
exist_z = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id=t.id,
|
||||||
|
mingzi=z.mingzi,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist_z:
|
||||||
|
z_skip += 1
|
||||||
|
else:
|
||||||
|
z_create += 1
|
||||||
|
# 商品去重按:目标类型下 同标题+同专区名(专区可能尚未建,用名推断)
|
||||||
|
for p in src_products:
|
||||||
|
src_z = next((x for x in src_zones if x.id == p.zhuanqu_id), None)
|
||||||
|
zname = src_z.mingzi if src_z else None
|
||||||
|
exist_zq = None
|
||||||
|
if zname:
|
||||||
|
exist_zq = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id=t.id,
|
||||||
|
mingzi=zname,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist_zq:
|
||||||
|
exist_p = Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id=t.id,
|
||||||
|
zhuanqu_id=exist_zq.id,
|
||||||
|
biaoqian=p.biaoqian,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist_p:
|
||||||
|
p_skip += 1
|
||||||
|
else:
|
||||||
|
p_create += 1
|
||||||
|
else:
|
||||||
|
p_create += 1
|
||||||
|
preview.append((t, z_create, z_skip, p_create, p_skip))
|
||||||
|
self.stdout.write(
|
||||||
|
f' 计划 →「{t.jieshao}」: zones +{z_create}/skip{z_skip}, '
|
||||||
|
f'products +{p_create}/skip{p_skip}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'\n以上仅计划,未写库。确认后:\n'
|
||||||
|
' python manage.py copy_lxs_shenhe_huhang_to_other_types '
|
||||||
|
'--apply --i-confirm-club=lxs\n'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'targets': 0,
|
||||||
|
'zones_created': 0,
|
||||||
|
'zones_skipped': 0,
|
||||||
|
'products_created': 0,
|
||||||
|
'products_skipped': 0,
|
||||||
|
'products_skip_no_zone': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for t in targets:
|
||||||
|
stats['targets'] += 1
|
||||||
|
zone_map = {} # src_zone_id -> dst_zone_id
|
||||||
|
|
||||||
|
for z in src_zones:
|
||||||
|
exist_z = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id=t.id,
|
||||||
|
mingzi=z.mingzi,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist_z:
|
||||||
|
zone_map[z.id] = exist_z.id
|
||||||
|
stats['zones_skipped'] += 1
|
||||||
|
continue
|
||||||
|
row = ShangpinZhuanqu.query.create(
|
||||||
|
mingzi=z.mingzi,
|
||||||
|
leixing_id=t.id,
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
tupian_url=z.tupian_url or '',
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
paixu=int(z.paixu or 0),
|
||||||
|
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
|
||||||
|
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
zone_map[z.id] = row.id
|
||||||
|
stats['zones_created'] += 1
|
||||||
|
|
||||||
|
for p in src_products:
|
||||||
|
dst_zq = zone_map.get(p.zhuanqu_id)
|
||||||
|
if not dst_zq:
|
||||||
|
stats['products_skip_no_zone'] += 1
|
||||||
|
self.stderr.write(
|
||||||
|
f'跳过无专区映射商品: {p.biaoqian!r} src_zq={p.zhuanqu_id} →「{t.jieshao}」'
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
exist_p = Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id=t.id,
|
||||||
|
zhuanqu_id=dst_zq,
|
||||||
|
biaoqian=p.biaoqian,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist_p:
|
||||||
|
stats['products_skipped'] += 1
|
||||||
|
continue
|
||||||
|
Shangpin.query.create(
|
||||||
|
biaoqian=p.biaoqian,
|
||||||
|
jiage=p.jiage,
|
||||||
|
kucun=p.kucun,
|
||||||
|
leixing_id=t.id,
|
||||||
|
zhuanqu_id=dst_zq,
|
||||||
|
zhenshi_xiaoliang=0,
|
||||||
|
duiwai_xiaoliang=0,
|
||||||
|
jieshao=p.jieshao,
|
||||||
|
xiadan_xuzhi=p.xiadan_xuzhi,
|
||||||
|
guize_tupian=p.guize_tupian or '',
|
||||||
|
tupian_url=p.tupian_url or '',
|
||||||
|
yaoqiuleixing=p.yaoqiuleixing,
|
||||||
|
huiyuan_id=p.huiyuan_id,
|
||||||
|
yongjin=p.yongjin,
|
||||||
|
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
|
||||||
|
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
|
||||||
|
paixu=int(p.paixu or 0),
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
shangjia_zhuangtai=bool(p.shangjia_zhuangtai),
|
||||||
|
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
|
||||||
|
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', True)),
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
shi_zhuanpan=False,
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
stats['products_created'] += 1
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'\n完成: {stats}\n'
|
||||||
|
f'源「{source.jieshao}」未改动;目标均为审核专用且仅 lxs。'
|
||||||
|
))
|
||||||
502
jituan/management/commands/copy_ufo_sanjiaozhou_catalog.py
Normal file
502
jituan/management/commands/copy_ufo_sanjiaozhou_catalog.py
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
将 UFO 俱乐部已上架的「三角洲」公共商品目录复制到龙先生俱乐部(只增不减)。
|
||||||
|
|
||||||
|
涉及表(只写目标俱乐部,不改源、不删任何行):
|
||||||
|
- club_shangpin_leixing_config ClubShangpinLeixingConfig 上架类型
|
||||||
|
- shangpin_zhuanqu ShangpinZhuanqu 公共专区
|
||||||
|
- shangpin Shangpin 公共商品
|
||||||
|
- OSS copy_in_oss 图物理复制到新相对路径
|
||||||
|
|
||||||
|
不碰:
|
||||||
|
- shangpin_leixing(全局类型只复用,不新建)
|
||||||
|
- 店铺商品(dianpu 非空)
|
||||||
|
- 订单/统计/轮播/转盘活动表
|
||||||
|
- UFO 及其它俱乐部任何行
|
||||||
|
|
||||||
|
用法(服务器):
|
||||||
|
|
||||||
|
cd /path/to/django
|
||||||
|
./venv/bin/python manage.py copy_ufo_sanjiaozhou_catalog --to-club=lxs
|
||||||
|
# 确认清单后:
|
||||||
|
./venv/bin/python manage.py copy_ufo_sanjiaozhou_catalog --to-club=lxs --apply --i-confirm-to-club=lxs
|
||||||
|
|
||||||
|
注意:龙先生 club_id 可能是 lxs 或 lsx,脚本不猜测,必须显式传 --to-club,
|
||||||
|
且 Club.name 必须包含「龙先生」。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
DEFAULT_FROM_CLUB = 'ufo'
|
||||||
|
DEFAULT_LEIXING_NAME = '三角洲'
|
||||||
|
NAME_HINT = '龙先生'
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
'复制 UFO「三角洲」公共上架商品目录到龙先生(默认 dry-run;'
|
||||||
|
'只 insert / 启用类型,不删不改源)'
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--from-club',
|
||||||
|
type=str,
|
||||||
|
default=DEFAULT_FROM_CLUB,
|
||||||
|
help=f'源俱乐部,默认 {DEFAULT_FROM_CLUB}',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--to-club',
|
||||||
|
type=str,
|
||||||
|
default='',
|
||||||
|
help='目标俱乐部 club_id(必须显式指定,如 lxs;--list-clubs 时可省略)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--leixing-name',
|
||||||
|
type=str,
|
||||||
|
default=DEFAULT_LEIXING_NAME,
|
||||||
|
help=f'全局类型名全名精确匹配,默认「{DEFAULT_LEIXING_NAME}」',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--leixing-id',
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help='可选:直接指定全局 ShangpinLeixing.id(仍校验名称)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--apply',
|
||||||
|
action='store_true',
|
||||||
|
help='真正写库 + OSS 复制(默认仅 dry-run)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--i-confirm-to-club',
|
||||||
|
type=str,
|
||||||
|
default='',
|
||||||
|
help='与 --to-club 完全一致时才允许 --apply',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--list-clubs',
|
||||||
|
action='store_true',
|
||||||
|
help='只列出 Club 表(含名称含「龙先生」的候选),不复制',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from jituan.models import Club, ClubShangpinLeixingConfig
|
||||||
|
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||||
|
from utils.oss_utils import copy_in_oss
|
||||||
|
|
||||||
|
if options.get('list_clubs'):
|
||||||
|
self._list_clubs(Club)
|
||||||
|
return
|
||||||
|
|
||||||
|
from_club = (options.get('from_club') or DEFAULT_FROM_CLUB).strip()
|
||||||
|
to_club = (options.get('to_club') or '').strip()
|
||||||
|
leixing_name = (options.get('leixing_name') or DEFAULT_LEIXING_NAME).strip()
|
||||||
|
apply = bool(options.get('apply'))
|
||||||
|
confirm = (options.get('i_confirm_to_club') or '').strip()
|
||||||
|
|
||||||
|
if not to_club:
|
||||||
|
raise CommandError(
|
||||||
|
'必须传 --to-club=...(龙先生当前库为 lxs)。'
|
||||||
|
'仅查看俱乐部列表时用:python manage.py copy_ufo_sanjiaozhou_catalog --list-clubs'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.NOTICE('—— Club 表候选(名称含「龙先生」)——'))
|
||||||
|
self._list_clubs(Club, only_hint=True)
|
||||||
|
|
||||||
|
src_club = Club.query.filter(club_id=from_club).first()
|
||||||
|
if not src_club:
|
||||||
|
raise CommandError(f'源俱乐部不存在: club_id={from_club!r}')
|
||||||
|
|
||||||
|
dst_club = Club.query.filter(club_id=to_club).first()
|
||||||
|
if not dst_club:
|
||||||
|
raise CommandError(
|
||||||
|
f'目标俱乐部不存在: club_id={to_club!r}。'
|
||||||
|
f'请先 --list-clubs 核对(常见:lxs 与 lsx 写反)。'
|
||||||
|
)
|
||||||
|
if NAME_HINT not in (dst_club.name or ''):
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:目标 club_id={to_club!r} 的 name={dst_club.name!r} '
|
||||||
|
f'不含「{NAME_HINT}」,拒绝执行。'
|
||||||
|
)
|
||||||
|
if from_club == to_club:
|
||||||
|
raise CommandError('源与目标 club_id 不能相同')
|
||||||
|
|
||||||
|
if apply:
|
||||||
|
if confirm != to_club:
|
||||||
|
raise CommandError(
|
||||||
|
'安全拦截:--apply 必须同时传 --i-confirm-to-club,且与 --to-club 完全一致。\n'
|
||||||
|
f' 例:--to-club={to_club} --apply --i-confirm-to-club={to_club}'
|
||||||
|
)
|
||||||
|
|
||||||
|
leixing = self._resolve_leixing(
|
||||||
|
ShangpinLeixing,
|
||||||
|
leixing_id=int(options.get('leixing_id') or 0),
|
||||||
|
leixing_name=leixing_name,
|
||||||
|
)
|
||||||
|
if leixing.shenhezhuangtai == 2:
|
||||||
|
raise CommandError(
|
||||||
|
f'类型 id={leixing.id}「{leixing.jieshao}」是审核专用,拒绝挂载'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 源:公共专区 + 上架商品(dianpu 为空 = 俱乐部公共目录)
|
||||||
|
src_zones = list(
|
||||||
|
ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=from_club,
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
shenhezhuangtai=1,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).order_by('paixu', 'id')
|
||||||
|
)
|
||||||
|
|
||||||
|
zone_ids = [z.id for z in src_zones]
|
||||||
|
src_products = []
|
||||||
|
if zone_ids:
|
||||||
|
src_products = list(
|
||||||
|
Shangpin.query.filter(
|
||||||
|
club_id=from_club,
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
zhuanqu_id__in=zone_ids,
|
||||||
|
shenhezhuangtai=1,
|
||||||
|
shangjia_zhuangtai=True,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).order_by('paixu', 'id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# 计划:Config
|
||||||
|
cfg_existing = ClubShangpinLeixingConfig.query.filter(
|
||||||
|
club_id=to_club, leixing_id=leixing.id,
|
||||||
|
).first()
|
||||||
|
if cfg_existing is None:
|
||||||
|
cfg_action = 'CREATE_ENABLE'
|
||||||
|
elif not cfg_existing.is_enabled:
|
||||||
|
cfg_action = 'ENABLE_EXISTING'
|
||||||
|
else:
|
||||||
|
cfg_action = 'SKIP_ALREADY_ENABLED'
|
||||||
|
|
||||||
|
# 计划:专区
|
||||||
|
zone_plans = []
|
||||||
|
for z in src_zones:
|
||||||
|
exist = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=to_club,
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
mingzi=z.mingzi,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist:
|
||||||
|
zone_plans.append({
|
||||||
|
'source': z,
|
||||||
|
'action': 'SKIP',
|
||||||
|
'target_id': exist.id,
|
||||||
|
'new_tupian_url': exist.tupian_url or '',
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
zone_plans.append({
|
||||||
|
'source': z,
|
||||||
|
'action': 'CREATE',
|
||||||
|
'target_id': None,
|
||||||
|
'new_tupian_url': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
zone_by_src_id = {p['source'].id: p for p in zone_plans}
|
||||||
|
|
||||||
|
product_plans = []
|
||||||
|
for p in src_products:
|
||||||
|
zp = zone_by_src_id.get(p.zhuanqu_id)
|
||||||
|
if not zp:
|
||||||
|
product_plans.append({
|
||||||
|
'source': p,
|
||||||
|
'action': 'SKIP_NO_ZONE',
|
||||||
|
'reason': f'zhuanqu_id={p.zhuanqu_id} 不在本批三角洲专区',
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
planned_zq_id = zp.get('target_id')
|
||||||
|
if planned_zq_id:
|
||||||
|
exist_p = Shangpin.query.filter(
|
||||||
|
club_id=to_club,
|
||||||
|
zhuanqu_id=planned_zq_id,
|
||||||
|
biaoqian=p.biaoqian,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist_p:
|
||||||
|
product_plans.append({
|
||||||
|
'source': p,
|
||||||
|
'action': 'SKIP',
|
||||||
|
'zone_plan': zp,
|
||||||
|
'target_zhuanqu_id': planned_zq_id,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
product_plans.append({
|
||||||
|
'source': p,
|
||||||
|
'action': 'CREATE',
|
||||||
|
'zone_plan': zp,
|
||||||
|
'target_zhuanqu_id': planned_zq_id,
|
||||||
|
})
|
||||||
|
|
||||||
|
# —— 打印确认清单 ——
|
||||||
|
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
f'\n===== {mode} =====\n'
|
||||||
|
f'源: {from_club} ({src_club.name}) → 目标: {to_club} ({dst_club.name})\n'
|
||||||
|
f'类型: jieshao={leixing.jieshao!r} id={leixing.id}(全局复用,不新建)\n'
|
||||||
|
f'ClubShangpinLeixingConfig: {cfg_action}\n'
|
||||||
|
f'源专区={len(src_zones)} 源上架商品={len(src_products)}\n'
|
||||||
|
))
|
||||||
|
|
||||||
|
self.stdout.write('—— 专区计划 ——')
|
||||||
|
for i, zp in enumerate(zone_plans, 1):
|
||||||
|
z = zp['source']
|
||||||
|
self.stdout.write(
|
||||||
|
f' [{i}] {zp["action"]} src_id={z.id} mingzi={z.mingzi!r} '
|
||||||
|
f'paixu={z.paixu} tupian={z.tupian_url or "-"}'
|
||||||
|
+ (f' → existing_id={zp["target_id"]}' if zp['target_id'] else '')
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write('—— 商品计划 ——')
|
||||||
|
for i, pp in enumerate(product_plans, 1):
|
||||||
|
p = pp['source']
|
||||||
|
zname = ''
|
||||||
|
zp = pp.get('zone_plan')
|
||||||
|
if zp:
|
||||||
|
zname = zp['source'].mingzi
|
||||||
|
self.stdout.write(
|
||||||
|
f' [{i}] {pp["action"]} src_id={p.id} {p.biaoqian!r} '
|
||||||
|
f'¥{p.jiage} zone={zname!r} '
|
||||||
|
f'tupian={p.tupian_url or "-"} guize={p.guize_tupian or "-"}'
|
||||||
|
+ (f' reason={pp.get("reason")}' if pp.get('reason') else '')
|
||||||
|
)
|
||||||
|
|
||||||
|
stats_preview = {
|
||||||
|
'cfg': cfg_action,
|
||||||
|
'zones_create': sum(1 for x in zone_plans if x['action'] == 'CREATE'),
|
||||||
|
'zones_skip': sum(1 for x in zone_plans if x['action'] == 'SKIP'),
|
||||||
|
'products_create': sum(1 for x in product_plans if x['action'] == 'CREATE'),
|
||||||
|
'products_skip': sum(1 for x in product_plans if x['action'] == 'SKIP'),
|
||||||
|
'products_skip_no_zone': sum(1 for x in product_plans if x['action'] == 'SKIP_NO_ZONE'),
|
||||||
|
}
|
||||||
|
self.stdout.write(self.style.NOTICE(f'\n统计预览: {stats_preview}'))
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'\n以上仅计划,未写库、未拷 OSS。\n'
|
||||||
|
'确认无误后执行:\n'
|
||||||
|
f' python manage.py copy_ufo_sanjiaozhou_catalog '
|
||||||
|
f'--to-club={to_club} --apply --i-confirm-to-club={to_club}\n'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
# —— APPLY:先 OSS,再 DB 事务 ——
|
||||||
|
def dest_key(category: str, src_url: str) -> str:
|
||||||
|
ext = 'png'
|
||||||
|
raw = (src_url or '').split('?', 1)[0]
|
||||||
|
if '.' in raw.rsplit('/', 1)[-1]:
|
||||||
|
ext = raw.rsplit('.', 1)[-1].lower()[:8] or 'png'
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
rand = uuid.uuid4().hex[:8]
|
||||||
|
return f'club/{to_club}/catalog/{category}/{ts}_{rand}.{ext}'
|
||||||
|
|
||||||
|
def copy_img(src_url: str, category: str) -> str:
|
||||||
|
if not src_url:
|
||||||
|
return ''
|
||||||
|
# 已是相对路径或 URL:物理复制到新 key
|
||||||
|
key = dest_key(category, src_url)
|
||||||
|
if not copy_in_oss(src_url, key):
|
||||||
|
raise CommandError(f'OSS 复制失败,已中止(未写库): {src_url} → {key}')
|
||||||
|
return key
|
||||||
|
|
||||||
|
# 预复制专区/商品图(仅 CREATE)
|
||||||
|
oss_ok = 0
|
||||||
|
for zp in zone_plans:
|
||||||
|
if zp['action'] != 'CREATE':
|
||||||
|
continue
|
||||||
|
z = zp['source']
|
||||||
|
if z.tupian_url:
|
||||||
|
zp['new_tupian_url'] = copy_img(z.tupian_url, 'zhuanqu')
|
||||||
|
oss_ok += 1
|
||||||
|
else:
|
||||||
|
zp['new_tupian_url'] = ''
|
||||||
|
|
||||||
|
for pp in product_plans:
|
||||||
|
if pp['action'] != 'CREATE':
|
||||||
|
continue
|
||||||
|
p = pp['source']
|
||||||
|
pp['new_tupian_url'] = copy_img(p.tupian_url, 'shangpin') if p.tupian_url else ''
|
||||||
|
if p.tupian_url:
|
||||||
|
oss_ok += 1
|
||||||
|
if p.guize_tupian:
|
||||||
|
pp['new_guize_tupian'] = copy_img(p.guize_tupian, 'guize')
|
||||||
|
oss_ok += 1
|
||||||
|
else:
|
||||||
|
pp['new_guize_tupian'] = ''
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'cfg_created': 0,
|
||||||
|
'cfg_enabled': 0,
|
||||||
|
'zones_created': 0,
|
||||||
|
'zones_skipped': 0,
|
||||||
|
'products_created': 0,
|
||||||
|
'products_skipped': 0,
|
||||||
|
'products_skipped_no_zone': 0,
|
||||||
|
'oss_copied': oss_ok,
|
||||||
|
}
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
cfg, created = ClubShangpinLeixingConfig.query.get_or_create(
|
||||||
|
club_id=to_club,
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
defaults={
|
||||||
|
'is_enabled': True,
|
||||||
|
'paixu': int(leixing.paixu or 0),
|
||||||
|
'tupian_url': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
stats['cfg_created'] = 1
|
||||||
|
elif not cfg.is_enabled:
|
||||||
|
cfg.is_enabled = True
|
||||||
|
cfg.save(update_fields=['is_enabled', 'UpdateTime'])
|
||||||
|
stats['cfg_enabled'] = 1
|
||||||
|
|
||||||
|
# 专区 insert + 映射
|
||||||
|
zone_id_map = {} # src_id -> dst_id
|
||||||
|
for zp in zone_plans:
|
||||||
|
z = zp['source']
|
||||||
|
if zp['action'] == 'SKIP':
|
||||||
|
zone_id_map[z.id] = zp['target_id']
|
||||||
|
stats['zones_skipped'] += 1
|
||||||
|
continue
|
||||||
|
# 事务内再查一次,防并发重复
|
||||||
|
exist = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=to_club,
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
mingzi=z.mingzi,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist:
|
||||||
|
zone_id_map[z.id] = exist.id
|
||||||
|
stats['zones_skipped'] += 1
|
||||||
|
continue
|
||||||
|
row = ShangpinZhuanqu.query.create(
|
||||||
|
mingzi=z.mingzi,
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
club_id=to_club,
|
||||||
|
tupian_url=zp.get('new_tupian_url') or '',
|
||||||
|
shenhezhuangtai=1,
|
||||||
|
paixu=int(z.paixu or 0),
|
||||||
|
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
|
||||||
|
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
zone_id_map[z.id] = row.id
|
||||||
|
zp['target_id'] = row.id
|
||||||
|
stats['zones_created'] += 1
|
||||||
|
|
||||||
|
for pp in product_plans:
|
||||||
|
p = pp['source']
|
||||||
|
if pp['action'] == 'SKIP_NO_ZONE':
|
||||||
|
stats['products_skipped_no_zone'] += 1
|
||||||
|
continue
|
||||||
|
if pp['action'] == 'SKIP':
|
||||||
|
stats['products_skipped'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
dst_zq = zone_id_map.get(p.zhuanqu_id)
|
||||||
|
if not dst_zq:
|
||||||
|
raise CommandError(
|
||||||
|
f'商品「{p.biaoqian}」src_id={p.id} 无法映射 zhuanqu_id={p.zhuanqu_id}'
|
||||||
|
)
|
||||||
|
|
||||||
|
exist_p = Shangpin.query.filter(
|
||||||
|
club_id=to_club,
|
||||||
|
zhuanqu_id=dst_zq,
|
||||||
|
biaoqian=p.biaoqian,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist_p:
|
||||||
|
stats['products_skipped'] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
Shangpin.query.create(
|
||||||
|
biaoqian=p.biaoqian,
|
||||||
|
jiage=p.jiage,
|
||||||
|
kucun=p.kucun,
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
zhuanqu_id=dst_zq,
|
||||||
|
zhenshi_xiaoliang=0,
|
||||||
|
duiwai_xiaoliang=0,
|
||||||
|
jieshao=p.jieshao,
|
||||||
|
xiadan_xuzhi=p.xiadan_xuzhi,
|
||||||
|
guize_tupian=pp.get('new_guize_tupian') or '',
|
||||||
|
yaoqiuleixing=p.yaoqiuleixing,
|
||||||
|
huiyuan_id=p.huiyuan_id,
|
||||||
|
yongjin=p.yongjin,
|
||||||
|
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
|
||||||
|
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
|
||||||
|
tupian_url=pp.get('new_tupian_url') or '',
|
||||||
|
paixu=int(p.paixu or 0),
|
||||||
|
shenhezhuangtai=1,
|
||||||
|
shangjia_zhuangtai=True,
|
||||||
|
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
|
||||||
|
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', False)),
|
||||||
|
club_id=to_club,
|
||||||
|
shi_zhuanpan=bool(getattr(p, 'shi_zhuanpan', False)),
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
stats['products_created'] += 1
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'\n写入完成: {stats}'))
|
||||||
|
self.stdout.write(
|
||||||
|
'说明:未复制 Zhuanpan*;全局 ShangpinLeixing 未改;源 UFO 数据未动。'
|
||||||
|
)
|
||||||
|
|
||||||
|
def _list_clubs(self, Club, only_hint=False):
|
||||||
|
qs = Club.query.order_by('club_id')
|
||||||
|
for c in qs:
|
||||||
|
name = c.name or ''
|
||||||
|
if only_hint and NAME_HINT not in name:
|
||||||
|
continue
|
||||||
|
mark = ' <<-- 龙先生候选' if NAME_HINT in name else ''
|
||||||
|
self.stdout.write(
|
||||||
|
f' club_id={c.club_id!r} name={name!r} status={c.status}{mark}'
|
||||||
|
)
|
||||||
|
if only_hint:
|
||||||
|
hits = [c for c in Club.query.order_by('club_id') if NAME_HINT in (c.name or '')]
|
||||||
|
if not hits:
|
||||||
|
self.stdout.write(self.style.WARNING(' (无名称含「龙先生」的俱乐部)'))
|
||||||
|
|
||||||
|
def _resolve_leixing(self, ShangpinLeixing, leixing_id: int, leixing_name: str):
|
||||||
|
if leixing_id:
|
||||||
|
row = ShangpinLeixing.query.filter(id=leixing_id).first()
|
||||||
|
if not row:
|
||||||
|
raise CommandError(f'找不到 ShangpinLeixing id={leixing_id}')
|
||||||
|
if (row.jieshao or '') != leixing_name:
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:--leixing-id={leixing_id} 的名称是「{row.jieshao}」,'
|
||||||
|
f'与要求的精确名「{leixing_name}」不一致。'
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
matches = list(ShangpinLeixing.query.filter(jieshao=leixing_name))
|
||||||
|
if not matches:
|
||||||
|
# 列出相近提示
|
||||||
|
self.stdout.write('全局商品类型清单:')
|
||||||
|
for lx in ShangpinLeixing.query.order_by('id'):
|
||||||
|
self.stdout.write(f' id={lx.id} name={lx.jieshao!r}')
|
||||||
|
raise CommandError(
|
||||||
|
f'未找到全局类型全名精确匹配「{leixing_name}」。'
|
||||||
|
f'请改后台类型名或 --leixing-id=... 指定。'
|
||||||
|
)
|
||||||
|
if len(matches) > 1:
|
||||||
|
ids = [m.id for m in matches]
|
||||||
|
raise CommandError(
|
||||||
|
f'全局类型「{leixing_name}」匹配到多条 id={ids},拒绝猜测。'
|
||||||
|
f'请用 --leixing-id=... 指定唯一一条。'
|
||||||
|
)
|
||||||
|
return matches[0]
|
||||||
427
jituan/management/commands/delete_club_by_mch_id.py
Normal file
427
jituan/management/commands/delete_club_by_mch_id.py
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
"""仅清理「龙先生电竞」(club_id=lsx) 的配置;绝对不碰其他俱乐部。
|
||||||
|
|
||||||
|
安全规则(硬约束):
|
||||||
|
1. 只允许操作 club_id=lsx(或显式传入且必须等于 lsx)
|
||||||
|
2. 若商户号命中了非 lsx 俱乐部:只打印警告,绝不删除
|
||||||
|
3. 默认 dry-run / --precheck:只列出将删内容与条数,不写库
|
||||||
|
4. --apply 必须再带 --i-understand-lsx-only 才真正删除
|
||||||
|
|
||||||
|
用法:
|
||||||
|
# 预检(推荐先跑这个,把输出发给你确认)
|
||||||
|
python manage.py delete_club_by_mch_id 17480975501 --precheck
|
||||||
|
|
||||||
|
# 确认无误后再删(双确认)
|
||||||
|
python manage.py delete_club_by_mch_id 17480975501 --apply --i-understand-lsx-only
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import connection, transaction
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from jituan.models import (
|
||||||
|
AdminAssignment,
|
||||||
|
Club,
|
||||||
|
ClubDashouExamConfig,
|
||||||
|
ClubDashouExamPass,
|
||||||
|
ClubDashouExamQuestion,
|
||||||
|
ClubDashouExamQuestionImage,
|
||||||
|
ClubFadanFenhongLilv,
|
||||||
|
ClubHuiyuanBundleInclude,
|
||||||
|
ClubHuiyuanPrice,
|
||||||
|
ClubKaoheChenghaoConfig,
|
||||||
|
ClubLilubiao,
|
||||||
|
ClubPaymentChannel,
|
||||||
|
ClubShangpinLeixingConfig,
|
||||||
|
ClubTixianQuota,
|
||||||
|
ClubWithdrawConfig,
|
||||||
|
IdentityTag,
|
||||||
|
IdentityTagBind,
|
||||||
|
PayChannelLedger,
|
||||||
|
UserWxOpenid,
|
||||||
|
)
|
||||||
|
from jituan.services.club_cert_upload import club_wx_cert_root
|
||||||
|
|
||||||
|
try:
|
||||||
|
from jituan.models import AdminAuditLog
|
||||||
|
except Exception:
|
||||||
|
AdminAuditLog = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from config.models import (
|
||||||
|
ClubMiniappIcon,
|
||||||
|
ClubPindao,
|
||||||
|
ClubPindaoImage,
|
||||||
|
Gonggao,
|
||||||
|
Lunbo,
|
||||||
|
MiniappScriptAutoReply,
|
||||||
|
MiniappScriptScene,
|
||||||
|
PopupPage,
|
||||||
|
Szjilu,
|
||||||
|
Tupianpeizhi,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
Lunbo = Gonggao = Tupianpeizhi = PopupPage = Szjilu = None
|
||||||
|
ClubMiniappIcon = ClubPindao = ClubPindaoImage = None
|
||||||
|
MiniappScriptScene = MiniappScriptAutoReply = None
|
||||||
|
|
||||||
|
# 本命令唯一允许删除的俱乐部
|
||||||
|
ALLOWED_CLUB_ID = 'lsx'
|
||||||
|
ALLOWED_CLUB_NAME_HINT = '龙先生'
|
||||||
|
|
||||||
|
# ORM 配置模型(均按 club_id 过滤)
|
||||||
|
ORM_MODELS = [
|
||||||
|
('支付通道 ClubPaymentChannel', ClubPaymentChannel),
|
||||||
|
('会员价 ClubHuiyuanPrice', ClubHuiyuanPrice),
|
||||||
|
('会员套餐 ClubHuiyuanBundleInclude', ClubHuiyuanBundleInclude),
|
||||||
|
('利率 ClubLilubiao', ClubLilubiao),
|
||||||
|
('提现配置 ClubWithdrawConfig', ClubWithdrawConfig),
|
||||||
|
('提现配额 ClubTixianQuota', ClubTixianQuota),
|
||||||
|
('商品类型上架 ClubShangpinLeixingConfig', ClubShangpinLeixingConfig),
|
||||||
|
('考核称号 ClubKaoheChenghaoConfig', ClubKaoheChenghaoConfig),
|
||||||
|
('发单分红利率 ClubFadanFenhongLilv', ClubFadanFenhongLilv),
|
||||||
|
('接单考试配置 ClubDashouExamConfig', ClubDashouExamConfig),
|
||||||
|
('接单考试题图 ClubDashouExamQuestionImage', ClubDashouExamQuestionImage),
|
||||||
|
('接单考试题 ClubDashouExamQuestion', ClubDashouExamQuestion),
|
||||||
|
('接单考试通过 ClubDashouExamPass', ClubDashouExamPass),
|
||||||
|
('身份标签绑定 IdentityTagBind', IdentityTagBind),
|
||||||
|
('身份标签 IdentityTag', IdentityTag),
|
||||||
|
('支付通道流水 PayChannelLedger', PayChannelLedger),
|
||||||
|
('OpenID绑定 UserWxOpenid', UserWxOpenid),
|
||||||
|
('客服任职 AdminAssignment', AdminAssignment),
|
||||||
|
('审计日志 AdminAuditLog', AdminAuditLog),
|
||||||
|
('轮播 Lunbo', Lunbo),
|
||||||
|
('公告 Gonggao', Gonggao),
|
||||||
|
('图片配置 Tupianpeizhi', Tupianpeizhi),
|
||||||
|
('弹窗页 PopupPage', PopupPage),
|
||||||
|
('收支记录配置 Szjilu', Szjilu),
|
||||||
|
('小程序图标 ClubMiniappIcon', ClubMiniappIcon),
|
||||||
|
('频道图 ClubPindaoImage', ClubPindaoImage),
|
||||||
|
('频道 ClubPindao', ClubPindao),
|
||||||
|
('话术场景 MiniappScriptScene', MiniappScriptScene),
|
||||||
|
('话术自动回复 MiniappScriptAutoReply', MiniappScriptAutoReply),
|
||||||
|
]
|
||||||
|
|
||||||
|
# SQL 兜底表(仅 DELETE WHERE club_id=%s,绝不会无 club_id 条件)
|
||||||
|
SQL_TABLES = [
|
||||||
|
'club_payment_channel',
|
||||||
|
'club_huiyuan_price',
|
||||||
|
'club_huiyuan_bundle_include',
|
||||||
|
'club_lilubiao',
|
||||||
|
'club_withdraw_config',
|
||||||
|
'club_tixian_quota',
|
||||||
|
'club_shangpin_leixing_config',
|
||||||
|
'club_kaohe_chenghao_config',
|
||||||
|
'club_fadan_fenhong_lilv',
|
||||||
|
'club_dashou_exam_config',
|
||||||
|
'club_dashou_exam_question',
|
||||||
|
'club_dashou_exam_question_image',
|
||||||
|
'club_dashou_exam_pass',
|
||||||
|
'identity_tag_bind',
|
||||||
|
'identity_tag',
|
||||||
|
'pay_channel_ledger',
|
||||||
|
'user_wx_openid',
|
||||||
|
'admin_assignment',
|
||||||
|
'admin_audit_log',
|
||||||
|
'lunbo',
|
||||||
|
'gonggao',
|
||||||
|
'tupianpeizhi',
|
||||||
|
'popup_page',
|
||||||
|
'szjilu',
|
||||||
|
'club_miniapp_icon',
|
||||||
|
'club_pindao',
|
||||||
|
'club_pindao_image',
|
||||||
|
'miniapp_script_scene',
|
||||||
|
'miniapp_script_auto_reply',
|
||||||
|
'daily_income_stat',
|
||||||
|
'daily_payout_stat',
|
||||||
|
'platform_income_log',
|
||||||
|
]
|
||||||
|
|
||||||
|
# 明确不删(业务数据)
|
||||||
|
NEVER_DELETE_NOTE = [
|
||||||
|
'User 用户主表 / 各身份扩展表(打手商家等)',
|
||||||
|
'订单 Order / 退款 / 处罚等业务流水',
|
||||||
|
'商品 Shangpin(若 club_id=lsx 有商品也不会在本命令删;如需另开命令)',
|
||||||
|
'其他俱乐部 xq / xzj / ufo 等任何数据',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _count_model(model, club_id):
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return model.query.filter(club_id=club_id).count()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_lsx_only(club_id: str):
|
||||||
|
cid = (club_id or '').strip()
|
||||||
|
if cid != ALLOWED_CLUB_ID:
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:本命令只允许操作 club_id={ALLOWED_CLUB_ID}(龙先生电竞),'
|
||||||
|
f'拒绝 club_id={cid!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _club_snapshot(club: Club):
|
||||||
|
return {
|
||||||
|
'club_id': club.club_id,
|
||||||
|
'name': club.name,
|
||||||
|
'status': club.status,
|
||||||
|
'wx_appid': club.wx_appid or '',
|
||||||
|
'official_appid': club.official_appid or '',
|
||||||
|
'official_secret_set': bool((club.official_secret or '').strip()),
|
||||||
|
'official_token_set': bool((club.official_token or '').strip()),
|
||||||
|
'encoding_aes_key_set': bool((club.encoding_aes_key or '').strip()),
|
||||||
|
'template_id': club.template_id or '',
|
||||||
|
'mch_id': club.mch_id or '',
|
||||||
|
'withdraw_mch_id': getattr(club, 'withdraw_mch_id', '') or '',
|
||||||
|
'pay_app_id': club.pay_app_id or '',
|
||||||
|
'pay_key_path': club.pay_key_path or '',
|
||||||
|
'pay_cert_path': club.pay_cert_path or '',
|
||||||
|
'private_key_path': club.private_key_path or '',
|
||||||
|
'platform_cert_dir': club.platform_cert_dir or '',
|
||||||
|
'goeasy_appkey_set': bool((club.goeasy_appkey or '').strip()),
|
||||||
|
'h5_domain': club.h5_domain or '',
|
||||||
|
'oss_prefix': club.oss_prefix or '',
|
||||||
|
'mini_pay_channel': getattr(club, 'mini_pay_channel', '') or '',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _find_other_clubs_with_mch(mch_id: str, exclude_club_id: str):
|
||||||
|
"""商户号撞到其他俱乐部时列出,绝不删除它们。"""
|
||||||
|
others = list(
|
||||||
|
Club.query.filter(
|
||||||
|
Q(mch_id=mch_id) | Q(withdraw_mch_id=mch_id)
|
||||||
|
).exclude(club_id=exclude_club_id)
|
||||||
|
)
|
||||||
|
ch_club_ids = (
|
||||||
|
ClubPaymentChannel.query.filter(mch_id=mch_id)
|
||||||
|
.exclude(club_id=exclude_club_id)
|
||||||
|
.values_list('club_id', flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
for cid in ch_club_ids:
|
||||||
|
if not any(c.club_id == cid for c in others):
|
||||||
|
c = Club.query.filter(club_id=cid).first()
|
||||||
|
if c:
|
||||||
|
others.append(c)
|
||||||
|
return others
|
||||||
|
|
||||||
|
|
||||||
|
def _precheck_report(stdout, style, club: Club, mch_id: str):
|
||||||
|
snap = _club_snapshot(club)
|
||||||
|
stdout.write(style.SUCCESS('=' * 64))
|
||||||
|
stdout.write(style.SUCCESS('【预检】仅针对龙先生电竞 club_id=lsx'))
|
||||||
|
stdout.write(style.SUCCESS('=' * 64))
|
||||||
|
stdout.write(f'请求商户号: {mch_id or "(未指定,仅按 lsx 清理)"}')
|
||||||
|
stdout.write('')
|
||||||
|
stdout.write('—— 俱乐部主表字段 ——')
|
||||||
|
for k, v in snap.items():
|
||||||
|
stdout.write(f' {k}: {v if v != "" else "(空)"}')
|
||||||
|
|
||||||
|
if mch_id:
|
||||||
|
club_mch = (club.mch_id or '').strip()
|
||||||
|
club_wm = (getattr(club, 'withdraw_mch_id', '') or '').strip()
|
||||||
|
ch_hit = ClubPaymentChannel.query.filter(club_id=ALLOWED_CLUB_ID, mch_id=mch_id).count()
|
||||||
|
matched = club_mch == mch_id or club_wm == mch_id or ch_hit > 0
|
||||||
|
if not matched:
|
||||||
|
stdout.write(style.WARNING(
|
||||||
|
f'\n注意:lsx 当前 mch_id={club_mch or "(空)"} / withdraw={club_wm or "(空)"} / '
|
||||||
|
f'通道命中={ch_hit},与请求商户号 {mch_id} 不完全一致。'
|
||||||
|
f'本命令仍只清理 lsx,不会动其他俱乐部。'
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
stdout.write(style.SUCCESS(f'\n商户号匹配确认:lsx 绑定了 {mch_id}'))
|
||||||
|
|
||||||
|
others = _find_other_clubs_with_mch(mch_id, ALLOWED_CLUB_ID)
|
||||||
|
if others:
|
||||||
|
stdout.write(style.ERROR(
|
||||||
|
f'\n!! 其他俱乐部也配置了商户号 {mch_id}(只警告,绝对不删):'
|
||||||
|
))
|
||||||
|
for o in others:
|
||||||
|
stdout.write(
|
||||||
|
f' - KEEP {o.club_id} ({o.name}) mch={o.mch_id} withdraw={getattr(o, "withdraw_mch_id", "")}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stdout.write('其他俱乐部未发现同商户号(安全)。')
|
||||||
|
|
||||||
|
stdout.write('\n—— 将删除的配置表(仅 club_id=lsx)——')
|
||||||
|
total = 0
|
||||||
|
for label, model in ORM_MODELS:
|
||||||
|
n = _count_model(model, ALLOWED_CLUB_ID)
|
||||||
|
if n is None:
|
||||||
|
stdout.write(f' [跳过不可用] {label}')
|
||||||
|
continue
|
||||||
|
mark = '将删' if n else '空'
|
||||||
|
stdout.write(f' [{mark}] {label}: {n} 条')
|
||||||
|
total += n or 0
|
||||||
|
|
||||||
|
stdout.write('\n—— SQL 兜底表计数(仅 WHERE club_id=lsx)——')
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
for table in SQL_TABLES:
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
f'SELECT COUNT(*) FROM `{table}` WHERE club_id=%s',
|
||||||
|
[ALLOWED_CLUB_ID],
|
||||||
|
)
|
||||||
|
n = cursor.fetchone()[0]
|
||||||
|
stdout.write(f' [{("将删" if n else "空")}] {table}: {n}')
|
||||||
|
total += n
|
||||||
|
except Exception as exc:
|
||||||
|
stdout.write(f' [无此表/跳过] {table}: {exc}')
|
||||||
|
|
||||||
|
cert_dir = os.path.join(club_wx_cert_root(), ALLOWED_CLUB_ID)
|
||||||
|
stdout.write('\n—— 证书目录 ——')
|
||||||
|
if os.path.isdir(cert_dir):
|
||||||
|
file_n = 0
|
||||||
|
for _root, _dirs, files in os.walk(cert_dir):
|
||||||
|
file_n += len(files)
|
||||||
|
stdout.write(f' [将删目录] {cert_dir} (约 {file_n} 个文件)')
|
||||||
|
else:
|
||||||
|
stdout.write(f' [无目录] {cert_dir}')
|
||||||
|
|
||||||
|
stdout.write('\n—— 明确不删 ——')
|
||||||
|
for tip in NEVER_DELETE_NOTE:
|
||||||
|
stdout.write(f' · {tip}')
|
||||||
|
|
||||||
|
stdout.write('')
|
||||||
|
stdout.write(style.SUCCESS(f'预检合计(配置行粗略合计,含重复计数可能):约 {total}'))
|
||||||
|
stdout.write(style.WARNING(
|
||||||
|
'确认无误后再执行:\n'
|
||||||
|
f' python manage.py delete_club_by_mch_id {mch_id or "17480975501"} '
|
||||||
|
'--apply --i-understand-lsx-only'
|
||||||
|
))
|
||||||
|
stdout.write(style.SUCCESS('=' * 64))
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_lsx(stdout, style, remove_cert_dir: bool):
|
||||||
|
_assert_lsx_only(ALLOWED_CLUB_ID)
|
||||||
|
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||||
|
if not club:
|
||||||
|
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在,无需删除')
|
||||||
|
|
||||||
|
# 名称再确认一层(非强制阻断,只警告)
|
||||||
|
if ALLOWED_CLUB_NAME_HINT not in (club.name or ''):
|
||||||
|
stdout.write(style.WARNING(
|
||||||
|
f'警告:{ALLOWED_CLUB_ID} 的 name={club.name!r} 不含「{ALLOWED_CLUB_NAME_HINT}」,'
|
||||||
|
f'但仍按 club_id=lsx 删除(主键约束)。'
|
||||||
|
))
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for label, model in ORM_MODELS:
|
||||||
|
if model is None:
|
||||||
|
continue
|
||||||
|
n = model.query.filter(club_id=ALLOWED_CLUB_ID).count()
|
||||||
|
if n:
|
||||||
|
model.query.filter(club_id=ALLOWED_CLUB_ID).delete()
|
||||||
|
stdout.write(f' - 已删 {label}: {n}')
|
||||||
|
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
for table in SQL_TABLES:
|
||||||
|
try:
|
||||||
|
cursor.execute(
|
||||||
|
f'SELECT COUNT(*) FROM `{table}` WHERE club_id=%s',
|
||||||
|
[ALLOWED_CLUB_ID],
|
||||||
|
)
|
||||||
|
n = cursor.fetchone()[0]
|
||||||
|
if n:
|
||||||
|
cursor.execute(
|
||||||
|
f'DELETE FROM `{table}` WHERE club_id=%s',
|
||||||
|
[ALLOWED_CLUB_ID],
|
||||||
|
)
|
||||||
|
stdout.write(f' - SQL 已删 {table}: {n}')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 最后删主表(再次校验主键)
|
||||||
|
deleted, _ = Club.query.filter(club_id=ALLOWED_CLUB_ID).delete()
|
||||||
|
stdout.write(style.SUCCESS(f' - 已删 Club 主表 lsx (affected={deleted})'))
|
||||||
|
|
||||||
|
if remove_cert_dir:
|
||||||
|
cert_dir = os.path.join(club_wx_cert_root(), ALLOWED_CLUB_ID)
|
||||||
|
if os.path.isdir(cert_dir):
|
||||||
|
shutil.rmtree(cert_dir, ignore_errors=True)
|
||||||
|
stdout.write(style.SUCCESS(f' - 已删证书目录 {cert_dir}'))
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '【仅 lsx/龙先生】按商户号预检/删除俱乐部配置;绝不碰其他俱乐部'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'mch_id',
|
||||||
|
nargs='?',
|
||||||
|
default='17480975501',
|
||||||
|
help='用于核对的商户号(默认 17480975501);删除范围仍固定为 lsx',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--precheck',
|
||||||
|
action='store_true',
|
||||||
|
help='只预检列出将删内容(默认行为也是预检)',
|
||||||
|
)
|
||||||
|
parser.add_argument('--apply', action='store_true', help='真正删除(需配合确认开关)')
|
||||||
|
parser.add_argument(
|
||||||
|
'--i-understand-lsx-only',
|
||||||
|
action='store_true',
|
||||||
|
help='确认仅删除龙先生 lsx,不涉及其他俱乐部',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--keep-cert-dir',
|
||||||
|
action='store_true',
|
||||||
|
help='保留磁盘 wx_certs/lsx 目录',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--club-id',
|
||||||
|
type=str,
|
||||||
|
default=ALLOWED_CLUB_ID,
|
||||||
|
help='必须为 lsx;传其他值会直接拒绝',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
club_id = (options.get('club_id') or ALLOWED_CLUB_ID).strip()
|
||||||
|
_assert_lsx_only(club_id)
|
||||||
|
|
||||||
|
mch_id = str(options.get('mch_id') or '').strip()
|
||||||
|
apply = bool(options.get('apply'))
|
||||||
|
confirm = bool(options.get('i_understand_lsx_only'))
|
||||||
|
# 默认/显式 precheck 都走预检;只有 apply+确认才删除
|
||||||
|
do_delete = apply and confirm
|
||||||
|
|
||||||
|
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||||
|
if not club:
|
||||||
|
raise CommandError(
|
||||||
|
f'未找到 club_id={ALLOWED_CLUB_ID}。不会删除任何其他俱乐部。'
|
||||||
|
)
|
||||||
|
|
||||||
|
_precheck_report(self.stdout, self.style, club, mch_id)
|
||||||
|
|
||||||
|
if not do_delete:
|
||||||
|
if apply and not confirm:
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
'\n已拒绝删除:缺少 --i-understand-lsx-only(防止误删)。'
|
||||||
|
))
|
||||||
|
self.stdout.write(self.style.WARNING('\n当前仅预检,数据库未改动。'))
|
||||||
|
return
|
||||||
|
|
||||||
|
# 删除前再拦一道:其他俱乐部同商户号只警告
|
||||||
|
others = _find_other_clubs_with_mch(mch_id, ALLOWED_CLUB_ID) if mch_id else []
|
||||||
|
if others:
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
'其他俱乐部存在同商户号配置,本命令仍只删 lsx,下列俱乐部保持不动:'
|
||||||
|
))
|
||||||
|
for o in others:
|
||||||
|
self.stdout.write(f' KEEP {o.club_id}')
|
||||||
|
|
||||||
|
self.stdout.write(self.style.WARNING('\n开始删除(仅 lsx)...'))
|
||||||
|
_delete_lsx(
|
||||||
|
self.stdout,
|
||||||
|
self.style,
|
||||||
|
remove_cert_dir=not bool(options.get('keep_cert_dir')),
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS('\n完成:仅龙先生 lsx 配置已清理。'))
|
||||||
178
jituan/management/commands/delete_ufo_public_catalog.py
Normal file
178
jituan/management/commands/delete_ufo_public_catalog.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
删除 UFO 俱乐部「正常/公共」商品目录(专区 + 商品)及对应 OSS 图片。
|
||||||
|
|
||||||
|
范围(必须全部满足,缺一不删):
|
||||||
|
- club_id 固定为 ufo(不可改目标)
|
||||||
|
- dianpu IS NULL(公共目录;店铺商品一律不碰)
|
||||||
|
- 表:shangpin、shangpin_zhuanqu
|
||||||
|
- OSS:商品 tupian_url / guize_tupian、专区 tupian_url
|
||||||
|
|
||||||
|
不碰:
|
||||||
|
- 其它俱乐部(xq / lxs / xzj 等)
|
||||||
|
- 店铺商品(dianpu 非空)
|
||||||
|
- 全局 ShangpinLeixing
|
||||||
|
- ClubShangpinLeixingConfig、订单、轮播、转盘、统计等
|
||||||
|
|
||||||
|
用法:
|
||||||
|
|
||||||
|
python manage.py delete_ufo_public_catalog
|
||||||
|
python manage.py delete_ufo_public_catalog --apply --i-confirm-club=ufo
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
ALLOWED_CLUB_ID = 'ufo'
|
||||||
|
NAME_HINT = 'UFO' # Club.name 须含此字样(大小写不敏感)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '删除 UFO 公共商品+专区及 OSS 图(默认 dry-run;仅 ufo)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--apply',
|
||||||
|
action='store_true',
|
||||||
|
help='真正删库 + 删 OSS(默认仅打印清单)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--i-confirm-club',
|
||||||
|
type=str,
|
||||||
|
default='',
|
||||||
|
help='必须等于 ufo,才允许 --apply',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--club-id',
|
||||||
|
type=str,
|
||||||
|
default=ALLOWED_CLUB_ID,
|
||||||
|
help='仅允许 ufo;传其它值会直接拒绝',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from jituan.models import Club
|
||||||
|
from products.models import Shangpin, ShangpinZhuanqu
|
||||||
|
from utils.oss_utils import delete_from_oss
|
||||||
|
|
||||||
|
club_id = (options.get('club_id') or '').strip()
|
||||||
|
apply = bool(options.get('apply'))
|
||||||
|
confirm = (options.get('i_confirm_club') or '').strip()
|
||||||
|
|
||||||
|
if club_id != ALLOWED_CLUB_ID:
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:本命令只允许 club_id={ALLOWED_CLUB_ID!r},你传的是 {club_id!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||||
|
if not club:
|
||||||
|
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在,无需删除')
|
||||||
|
if NAME_HINT.lower() not in (club.name or '').lower():
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:club_id={ALLOWED_CLUB_ID!r} 的 name={club.name!r} '
|
||||||
|
f'不含「{NAME_HINT}」,拒绝执行。'
|
||||||
|
)
|
||||||
|
|
||||||
|
if apply and confirm != ALLOWED_CLUB_ID:
|
||||||
|
raise CommandError(
|
||||||
|
'安全拦截:--apply 必须同时传 --i-confirm-club=ufo\n'
|
||||||
|
' 例:python manage.py delete_ufo_public_catalog --apply --i-confirm-club=ufo'
|
||||||
|
)
|
||||||
|
|
||||||
|
zones = list(
|
||||||
|
ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).order_by('id')
|
||||||
|
)
|
||||||
|
products = list(
|
||||||
|
Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).order_by('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
oss_urls = []
|
||||||
|
for z in zones:
|
||||||
|
if z.tupian_url:
|
||||||
|
oss_urls.append(('zone', z.id, z.tupian_url))
|
||||||
|
for p in products:
|
||||||
|
if p.tupian_url:
|
||||||
|
oss_urls.append(('product', p.id, p.tupian_url))
|
||||||
|
if p.guize_tupian:
|
||||||
|
oss_urls.append(('product_guize', p.id, p.guize_tupian))
|
||||||
|
|
||||||
|
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
f'\n===== {mode} 删除 UFO 公共商品目录 =====\n'
|
||||||
|
f'club_id={ALLOWED_CLUB_ID} name={club.name!r}\n'
|
||||||
|
f'公共专区={len(zones)} 公共商品={len(products)} '
|
||||||
|
f'待删 OSS 路径={len(oss_urls)}\n'
|
||||||
|
))
|
||||||
|
|
||||||
|
self.stdout.write('—— 专区 ——')
|
||||||
|
for z in zones[:50]:
|
||||||
|
self.stdout.write(
|
||||||
|
f' id={z.id} mingzi={z.mingzi!r} leixing_id={z.leixing_id} '
|
||||||
|
f'tupian={z.tupian_url or "-"}'
|
||||||
|
)
|
||||||
|
if len(zones) > 50:
|
||||||
|
self.stdout.write(f' ... 其余 {len(zones) - 50} 个专区省略')
|
||||||
|
|
||||||
|
self.stdout.write('—— 商品 ——')
|
||||||
|
for p in products[:80]:
|
||||||
|
self.stdout.write(
|
||||||
|
f' id={p.id} {p.biaoqian!r} ¥{p.jiage} '
|
||||||
|
f'zhuanqu_id={p.zhuanqu_id} tupian={p.tupian_url or "-"}'
|
||||||
|
)
|
||||||
|
if len(products) > 80:
|
||||||
|
self.stdout.write(f' ... 其余 {len(products) - 80} 个商品省略')
|
||||||
|
|
||||||
|
self.stdout.write(f'—— OSS 将删 {len(oss_urls)} 个对象(相对路径)——')
|
||||||
|
for kind, oid, url in oss_urls[:30]:
|
||||||
|
self.stdout.write(f' [{kind}#{oid}] {url}')
|
||||||
|
if len(oss_urls) > 30:
|
||||||
|
self.stdout.write(f' ... 其余 {len(oss_urls) - 30} 个省略')
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'\n以上仅计划,未删库、未删 OSS。\n'
|
||||||
|
'确认无误后:\n'
|
||||||
|
' python manage.py delete_ufo_public_catalog --apply --i-confirm-club=ufo\n'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not zones and not products:
|
||||||
|
self.stdout.write(self.style.SUCCESS('无公共专区/商品,无需删除。'))
|
||||||
|
return
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# 再按条件删一遍,避免误伤其它 club / 店铺商品
|
||||||
|
n_prod, _ = Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).delete()
|
||||||
|
n_zone, _ = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
oss_ok = 0
|
||||||
|
oss_fail = 0
|
||||||
|
seen = set()
|
||||||
|
for _, _, url in oss_urls:
|
||||||
|
key = (url or '').strip()
|
||||||
|
if not key or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
if delete_from_oss(key):
|
||||||
|
oss_ok += 1
|
||||||
|
else:
|
||||||
|
oss_fail += 1
|
||||||
|
self.stderr.write(f'OSS 删除失败(已记日志,继续): {key}')
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'\n删除完成: products={n_prod} zones={n_zone} '
|
||||||
|
f'oss_ok={oss_ok} oss_fail={oss_fail}\n'
|
||||||
|
f'未动:其它俱乐部、店铺商品、全局类型、订单/转盘等。'
|
||||||
|
))
|
||||||
127
jituan/management/commands/diagnose_lsx_invite.py
Normal file
127
jituan/management/commands/diagnose_lsx_invite.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""诊断 / 对齐龙先生邀请种子与小程序 club_id。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python manage.py diagnose_lsx_invite
|
||||||
|
python manage.py diagnose_lsx_invite --align-to lsx
|
||||||
|
python manage.py diagnose_lsx_invite --align-to-appid
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from jituan.models import Club
|
||||||
|
from users.business_models import User
|
||||||
|
from users.models import UserGuanshi, UserZuzhang
|
||||||
|
|
||||||
|
LSX_APPID = 'wx7ff90e9d024fcdb8'
|
||||||
|
SEED_UIDS = ('9910001', '9910002')
|
||||||
|
FRONTEND_EXPECT = 'lsx'
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '诊断龙先生种子邀请人 ClubID 与小程序是否一致,并可对齐'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--align-to',
|
||||||
|
type=str,
|
||||||
|
default='',
|
||||||
|
help='把种子用户 ClubID 改成指定值(如 lsx)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--align-to-appid',
|
||||||
|
action='store_true',
|
||||||
|
help='按 wx_appid=龙先生 找到俱乐部,把种子 ClubID 对齐过去',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||||
|
self.stdout.write('前端龙先生小程序默认 X-Club-Id / CLUB_ID = lsx')
|
||||||
|
self.stdout.write(f'龙先生 AppID = {LSX_APPID}')
|
||||||
|
self.stdout.write('')
|
||||||
|
|
||||||
|
clubs = list(Club.query.order_by('club_id'))
|
||||||
|
self.stdout.write(f'库里俱乐部 {len(clubs)} 个:')
|
||||||
|
target_by_app = None
|
||||||
|
for c in clubs:
|
||||||
|
mark = ''
|
||||||
|
if c.club_id == FRONTEND_EXPECT:
|
||||||
|
mark = ' ← 前端期望'
|
||||||
|
if (c.wx_appid or '') == LSX_APPID:
|
||||||
|
mark += ' ← AppID命中'
|
||||||
|
target_by_app = c
|
||||||
|
if '龙先生' in (c.name or ''):
|
||||||
|
mark += ' ← 名称命中'
|
||||||
|
self.stdout.write(
|
||||||
|
f' club_id={c.club_id!r} name={c.name!r} wx_appid={c.wx_appid or "(空)"}{mark}'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write('种子用户:')
|
||||||
|
for uid in SEED_UIDS:
|
||||||
|
user = User.query.filter(UserUID=uid).first()
|
||||||
|
if not user:
|
||||||
|
self.stdout.write(self.style.WARNING(f' {uid}: 不存在(还没跑 seed_lsx_invite_data?)'))
|
||||||
|
continue
|
||||||
|
role = []
|
||||||
|
try:
|
||||||
|
z = user.ZuzhangProfile
|
||||||
|
role.append(f'组长码={z.yaoqingma}')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
g = user.GuanshiProfile
|
||||||
|
role.append(f'管事码={g.yaoqingma}')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
club = (user.ClubID or '').strip()
|
||||||
|
ok = club == FRONTEND_EXPECT
|
||||||
|
line = f' {uid}: ClubID={club or "(空)"} {" | ".join(role) or "无身份"}'
|
||||||
|
if ok:
|
||||||
|
self.stdout.write(self.style.SUCCESS(line + ' ✓ 与前端 lsx 一致'))
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
line + f' ✗ 与前端 lsx 不一致 → 会报「邀请码不属于当前小程序俱乐部」'
|
||||||
|
))
|
||||||
|
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'结论:这是后端校验(invite_guard),不是前端邀请码输错。\n'
|
||||||
|
'小程序发 X-Club-Id=lsx,邀请人 User.ClubID 必须也是 lsx。'
|
||||||
|
))
|
||||||
|
|
||||||
|
align_to = (options.get('align_to') or '').strip()
|
||||||
|
if options.get('align_to_appid'):
|
||||||
|
if not target_by_app:
|
||||||
|
raise CommandError(f'没有 wx_appid={LSX_APPID} 的俱乐部,无法对齐')
|
||||||
|
align_to = target_by_app.club_id
|
||||||
|
self.stdout.write(f'将按 AppID 对齐到 club_id={align_to!r}')
|
||||||
|
|
||||||
|
if not align_to:
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write('若要修复,任选其一:')
|
||||||
|
self.stdout.write(' python manage.py diagnose_lsx_invite --align-to lsx')
|
||||||
|
self.stdout.write(' python manage.py diagnose_lsx_invite --align-to-appid')
|
||||||
|
self.stdout.write('然后前端 CLUB_ID 必须与种子 ClubID 相同。')
|
||||||
|
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not Club.query.filter(club_id=align_to).exists():
|
||||||
|
raise CommandError(f'目标俱乐部 {align_to!r} 不存在,请先在后台创建')
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
for uid in SEED_UIDS:
|
||||||
|
user = User.query.filter(UserUID=uid).first()
|
||||||
|
if not user:
|
||||||
|
self.stdout.write(self.style.WARNING(f'跳过不存在用户 {uid}'))
|
||||||
|
continue
|
||||||
|
old = user.ClubID
|
||||||
|
user.ClubID = align_to
|
||||||
|
user.save(update_fields=['ClubID'])
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'已更新 {uid}: ClubID {old!r} → {align_to!r}'
|
||||||
|
))
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'\n对齐完成。请确认小程序 config/club-config.js 的 CLUB_ID = {align_to!r}'
|
||||||
|
))
|
||||||
|
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||||
177
jituan/management/commands/import_ufo_banners.py
Normal file
177
jituan/management/commands/import_ufo_banners.py
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
仅导入 UFO 展示图到 Lunbo(不碰商品)。只写 club_id=ufo。
|
||||||
|
|
||||||
|
覆盖:
|
||||||
|
- accept_order 首页主轮播
|
||||||
|
- accept_order_banner2 首页第二横幅
|
||||||
|
- boss_center 「我的」顶区背景(原站 bg_img)
|
||||||
|
|
||||||
|
默认 dry-run;--apply 才写库。只删/写 ufo 对应 page_key,不影响 xq/xzj。
|
||||||
|
|
||||||
|
cd /opt/1panel/apps/openresty/nginx/www/sites/43.142.166.152.443/django
|
||||||
|
git pull origin main
|
||||||
|
./venv/bin/python manage.py import_ufo_banners
|
||||||
|
./venv/bin/python manage.py import_ufo_banners --apply
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
TARGET_CLUB = 'ufo'
|
||||||
|
DEFAULT_CATALOG = Path(__file__).resolve().parents[2] / 'data' / 'ufo_catalog.json'
|
||||||
|
|
||||||
|
# 原站「我的」顶区背景(getUserCenterInfo.bg_img);catalog 里没有时用这个兜底
|
||||||
|
BOSS_CENTER_FALLBACK = (
|
||||||
|
'https://ufo.qn.17v.tech/uploads/20251001/Fls0cQjtfsV7IUXl4cwqO6BFvPyh.jpg'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '导入 UFO 轮播 + 我的背景到 Lunbo(仅 club=ufo)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--apply', action='store_true')
|
||||||
|
parser.add_argument('--catalog', type=str, default='')
|
||||||
|
parser.add_argument('--skip-oss', action='store_true')
|
||||||
|
parser.add_argument('--club-id', type=str, default=TARGET_CLUB)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
club_id = (options.get('club_id') or '').strip()
|
||||||
|
if club_id != TARGET_CLUB:
|
||||||
|
raise CommandError(f'只允许 club_id={TARGET_CLUB}')
|
||||||
|
|
||||||
|
catalog_path = Path(options['catalog']).expanduser() if options['catalog'] else DEFAULT_CATALOG
|
||||||
|
if not catalog_path.is_file():
|
||||||
|
raise CommandError(f'找不到 {catalog_path}')
|
||||||
|
|
||||||
|
data = json.loads(catalog_path.read_text(encoding='utf-8'))
|
||||||
|
banners = list(data.get('banners') or [])
|
||||||
|
banners2 = list(data.get('banners2') or [])
|
||||||
|
boss = list(data.get('boss_center') or [])
|
||||||
|
if not boss:
|
||||||
|
# catalog 内嵌或 misc
|
||||||
|
misc = data.get('misc') or {}
|
||||||
|
bg = misc.get('bg_img') or BOSS_CENTER_FALLBACK
|
||||||
|
boss = [{'url': bg, 'kind': 'boss_center'}]
|
||||||
|
|
||||||
|
apply = bool(options['apply'])
|
||||||
|
skip_oss = bool(options['skip_oss'])
|
||||||
|
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
f'club={TARGET_CLUB} accept_order={len(banners)} '
|
||||||
|
f'banner2={len(banners2)} boss_center={len(boss)} '
|
||||||
|
f'mode={"APPLY" if apply else "DRY-RUN"}'
|
||||||
|
))
|
||||||
|
for b in banners:
|
||||||
|
self.stdout.write(f' [主轮播] {b.get("url")}')
|
||||||
|
for b in banners2:
|
||||||
|
self.stdout.write(f' [第二横幅] {b.get("url")}')
|
||||||
|
for b in boss:
|
||||||
|
self.stdout.write(f' [我的背景] {b.get("url")}')
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'dry-run。确认后:\n ./venv/bin/python manage.py import_ufo_banners --apply'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
from config.models import Lunbo
|
||||||
|
from jituan.models import Club
|
||||||
|
from jituan.services.display_config import (
|
||||||
|
LUNBO_PAGE_ACCEPT_BANNER2,
|
||||||
|
LUNBO_PAGE_ACCEPT_ORDER,
|
||||||
|
LUNBO_PAGE_BOSS_CENTER,
|
||||||
|
lunbo_image_type_for_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not Club.query.filter(club_id=TARGET_CLUB).exists():
|
||||||
|
raise CommandError('俱乐部 ufo 不存在')
|
||||||
|
|
||||||
|
stats = {'ok': 0, 'fail': 0}
|
||||||
|
|
||||||
|
def to_oss(url: str) -> str:
|
||||||
|
if not url:
|
||||||
|
return ''
|
||||||
|
if skip_oss:
|
||||||
|
return url
|
||||||
|
if not str(url).startswith('http'):
|
||||||
|
return url
|
||||||
|
path = self._upload_remote(url)
|
||||||
|
if not path:
|
||||||
|
stats['fail'] += 1
|
||||||
|
raise CommandError(f'OSS 上传失败: {url}')
|
||||||
|
stats['ok'] += 1
|
||||||
|
return path
|
||||||
|
|
||||||
|
def replace_page(page_key, items):
|
||||||
|
image_type = lunbo_image_type_for_page(page_key)
|
||||||
|
# 只删本俱乐部本 page_key
|
||||||
|
deleted, _ = Lunbo.query.filter(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
page_key=page_key,
|
||||||
|
ImageType=image_type,
|
||||||
|
).delete()
|
||||||
|
self.stdout.write(f' clear {page_key} deleted={deleted}')
|
||||||
|
n = 0
|
||||||
|
for b in items:
|
||||||
|
url = to_oss(b.get('url') or b.get('path') or '')
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
Lunbo.query.create(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
page_key=page_key,
|
||||||
|
ImageURL=url,
|
||||||
|
ImageType=image_type,
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
n1 = replace_page(LUNBO_PAGE_ACCEPT_ORDER, banners)
|
||||||
|
n2 = replace_page(LUNBO_PAGE_ACCEPT_BANNER2, banners2)
|
||||||
|
n3 = replace_page(LUNBO_PAGE_BOSS_CENTER, boss)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'完成仅 ufo:主轮播={n1} 第二横幅={n2} 我的背景={n3} '
|
||||||
|
f'OSS成功={stats["ok"]} 失败={stats["fail"]}'
|
||||||
|
))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _upload_remote(url: str) -> str:
|
||||||
|
from utils.oss_utils import upload_to_oss
|
||||||
|
|
||||||
|
try:
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
req = Request(url, headers={'User-Agent': 'UFO-Import/1.0'})
|
||||||
|
with urlopen(req, timeout=60, context=ctx) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
ctype = resp.headers.get('Content-Type') or ''
|
||||||
|
except Exception:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
ext = mimetypes.guess_extension((ctype or '').split(';')[0].strip()) or ''
|
||||||
|
if not ext or ext == '.jpe':
|
||||||
|
m = re.search(r'\.(jpg|jpeg|png|gif|webp|bmp)(?:\?|$)', url, re.I)
|
||||||
|
ext = ('.' + m.group(1).lower()) if m else '.jpg'
|
||||||
|
if ext == '.jpeg':
|
||||||
|
ext = '.jpg'
|
||||||
|
|
||||||
|
ts = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||||
|
rel = f"a_long/lunbo/{ts}_{uuid.uuid4().hex[:8]}{ext}"
|
||||||
|
bio = BytesIO(raw)
|
||||||
|
bio.name = os.path.basename(rel)
|
||||||
|
ok = upload_to_oss(bio, rel)
|
||||||
|
return rel if ok else ''
|
||||||
415
jituan/management/commands/import_ufo_catalog.py
Normal file
415
jituan/management/commands/import_ufo_catalog.py
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
将原 UFO 站点商品目录灌入星阙,且只写 club_id=ufo。
|
||||||
|
|
||||||
|
默认 dry-run(只打印计划,不写库)。真正写入必须加 --apply。
|
||||||
|
默认按全局类型名「三角洲」**全名精确匹配**找 id(不含这三字的一律不要)。
|
||||||
|
找到唯一一条后:
|
||||||
|
1) ClubShangpinLeixingConfig 给 club=ufo 上架该类型
|
||||||
|
2) ShangpinZhuanqu / Shangpin 的 leixing_id 挂这个全局类型 id,且 club_id=ufo
|
||||||
|
|
||||||
|
服务器上(复制整段):
|
||||||
|
|
||||||
|
cd /opt/1panel/apps/openresty/nginx/www/sites/43.142.166.152.443/django
|
||||||
|
git pull origin main
|
||||||
|
./venv/bin/python manage.py import_ufo_catalog
|
||||||
|
./venv/bin/python manage.py import_ufo_catalog --apply
|
||||||
|
./venv/bin/python manage.py import_ufo_catalog --apply --with-banners
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import ssl
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
TARGET_CLUB = 'ufo'
|
||||||
|
DEFAULT_LEIXING_NAME = '三角洲' # 全名精确匹配,不模糊
|
||||||
|
DEFAULT_CATALOG = Path(__file__).resolve().parents[2] / 'data' / 'ufo_catalog.json'
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '导入 UFO 商品/专区到 club_id=ufo(默认 dry-run;类型名默认精确匹配「三角洲」)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--apply', action='store_true', help='真正写库(仅 ufo)')
|
||||||
|
parser.add_argument('--catalog', type=str, default='', help='catalog.json 路径')
|
||||||
|
parser.add_argument(
|
||||||
|
'--list-leixing',
|
||||||
|
action='store_true',
|
||||||
|
help='只列出全局商品类型 id/名称',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--leixing-id',
|
||||||
|
type=int,
|
||||||
|
default=0,
|
||||||
|
help='可选:直接指定全局类型主键;不传则按 --leixing-name 精确匹配',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--leixing-name',
|
||||||
|
type=str,
|
||||||
|
default=DEFAULT_LEIXING_NAME,
|
||||||
|
help='全局 ShangpinLeixing.jieshao 全名精确匹配,默认:三角洲',
|
||||||
|
)
|
||||||
|
parser.add_argument('--with-banners', action='store_true', help='同时导入点单主轮播+第二横幅')
|
||||||
|
parser.add_argument('--skip-oss', action='store_true', help='不上传 OSS,保留原 URL(应急)')
|
||||||
|
parser.add_argument('--club-id', type=str, default=TARGET_CLUB, help='仅允许 ufo')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from products.models import ShangpinLeixing
|
||||||
|
|
||||||
|
if options.get('list_leixing'):
|
||||||
|
self.stdout.write('全局商品类型清单(ShangpinLeixing):')
|
||||||
|
for lx in ShangpinLeixing.query.order_by('id'):
|
||||||
|
mark = ' <<-- 精确名=三角洲' if (lx.jieshao or '') == DEFAULT_LEIXING_NAME else ''
|
||||||
|
self.stdout.write(
|
||||||
|
f' id={lx.id} name={lx.jieshao!r} '
|
||||||
|
f'shenhezhuangtai={lx.shenhezhuangtai} paixu={lx.paixu}{mark}'
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
'默认会精确匹配 name==「三角洲」;也可 --leixing-id=... 覆盖。\n'
|
||||||
|
'俱乐部上架表 ClubShangpinLeixingConfig 会在 --apply 时给 ufo 自动开启该类型。'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
club_id = (options.get('club_id') or '').strip()
|
||||||
|
if club_id != TARGET_CLUB:
|
||||||
|
raise CommandError(
|
||||||
|
f'拒绝执行:只允许 club_id={TARGET_CLUB},你传的是 {club_id!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog_path = Path(options['catalog']).expanduser() if options['catalog'] else DEFAULT_CATALOG
|
||||||
|
if not catalog_path.is_file():
|
||||||
|
raise CommandError(f'找不到目录文件: {catalog_path}')
|
||||||
|
|
||||||
|
data = json.loads(catalog_path.read_text(encoding='utf-8'))
|
||||||
|
products = data.get('products') or []
|
||||||
|
zones = data.get('zones') or []
|
||||||
|
banners = data.get('banners') or []
|
||||||
|
banners2 = data.get('banners2') or []
|
||||||
|
apply = bool(options['apply'])
|
||||||
|
with_banners = bool(options['with_banners'])
|
||||||
|
skip_oss = bool(options['skip_oss'])
|
||||||
|
|
||||||
|
from jituan.models import Club, ClubShangpinLeixingConfig
|
||||||
|
from products.models import Shangpin, ShangpinZhuanqu
|
||||||
|
|
||||||
|
if not Club.query.filter(club_id=TARGET_CLUB).exists():
|
||||||
|
raise CommandError(f'俱乐部 {TARGET_CLUB} 不存在,请先 create_club ufo')
|
||||||
|
|
||||||
|
leixing = self._resolve_leixing(
|
||||||
|
ShangpinLeixing,
|
||||||
|
leixing_id=int(options.get('leixing_id') or 0),
|
||||||
|
leixing_name=(options.get('leixing_name') or '').strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
f'目标俱乐部={TARGET_CLUB} 类型={leixing.jieshao!r}(id={leixing.id}) '
|
||||||
|
f'shenhezhuangtai={leixing.shenhezhuangtai} '
|
||||||
|
f'专区={len(zones)} 商品={len(products)} banners={len(banners)}+{len(banners2)} '
|
||||||
|
f'mode={"APPLY" if apply else "DRY-RUN"}'
|
||||||
|
))
|
||||||
|
if leixing.shenhezhuangtai == 2:
|
||||||
|
raise CommandError(
|
||||||
|
f'类型 id={leixing.id}「{leixing.jieshao}」是审核专用,拒绝挂载'
|
||||||
|
)
|
||||||
|
expect_name = (options.get('leixing_name') or DEFAULT_LEIXING_NAME).strip()
|
||||||
|
if (leixing.jieshao or '') != expect_name and not int(options.get('leixing_id') or 0):
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:解析到的类型名是「{leixing.jieshao}」,'
|
||||||
|
f'与要求的精确名「{expect_name}」不一致。'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f'将写入:ClubShangpinLeixingConfig(club=ufo, leixing_id={leixing.id}, is_enabled=True);'
|
||||||
|
f'专区/商品 leixing_id={leixing.id}, club_id=ufo'
|
||||||
|
)
|
||||||
|
|
||||||
|
for z in zones:
|
||||||
|
self.stdout.write(f' 专区: {z.get("name")} goods={len(z.get("goods_ids") or [])}')
|
||||||
|
for p in products[:5]:
|
||||||
|
self.stdout.write(
|
||||||
|
f' 商品样例: [{p.get("source_id")}] {p.get("title")} ¥{p.get("price")} → {p.get("zone_name")}'
|
||||||
|
)
|
||||||
|
if len(products) > 5:
|
||||||
|
self.stdout.write(f' ... 其余 {len(products) - 5} 个商品省略')
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'当前 dry-run,未写库。确认无误后:\n'
|
||||||
|
' ./venv/bin/python manage.py import_ufo_catalog --apply\n'
|
||||||
|
' ./venv/bin/python manage.py import_ufo_catalog --apply --with-banners'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'leixing_enabled': 0,
|
||||||
|
'zones_created': 0,
|
||||||
|
'zones_updated': 0,
|
||||||
|
'products_created': 0,
|
||||||
|
'products_updated': 0,
|
||||||
|
'banners': 0,
|
||||||
|
'oss_ok': 0,
|
||||||
|
'oss_fail': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_oss(url: str, prefix: str) -> str:
|
||||||
|
"""下载原图 → 上传到本站 OSS → 返回相对路径(与后台 AddProduct 一致)。"""
|
||||||
|
if not url:
|
||||||
|
return ''
|
||||||
|
if skip_oss:
|
||||||
|
# 应急:允许写完整 URL;正式导入不要加 --skip-oss
|
||||||
|
return url
|
||||||
|
if not str(url).startswith('http'):
|
||||||
|
return url
|
||||||
|
path = self._upload_remote(url, prefix)
|
||||||
|
if not path:
|
||||||
|
stats['oss_fail'] += 1
|
||||||
|
raise CommandError(f'OSS 上传失败,已中止以免库里写入外站链接: {url}')
|
||||||
|
stats['oss_ok'] += 1
|
||||||
|
return path
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
cfg, created = ClubShangpinLeixingConfig.query.get_or_create(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
defaults={
|
||||||
|
'is_enabled': True,
|
||||||
|
'paixu': int(leixing.paixu or 0),
|
||||||
|
'tupian_url': '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not cfg.is_enabled:
|
||||||
|
cfg.is_enabled = True
|
||||||
|
cfg.save(update_fields=['is_enabled', 'UpdateTime'])
|
||||||
|
stats['leixing_enabled'] += 1
|
||||||
|
elif created:
|
||||||
|
stats['leixing_enabled'] += 1
|
||||||
|
|
||||||
|
zone_id_map = {}
|
||||||
|
for idx, z in enumerate(zones):
|
||||||
|
name = (z.get('name') or '').strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
src_id = int(z.get('source_id') or 0)
|
||||||
|
thumb = to_oss(z.get('thumb_url') or '', 'shangpin/zhuanqu')
|
||||||
|
row = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
mingzi=name,
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if row:
|
||||||
|
row.tupian_url = thumb or row.tupian_url
|
||||||
|
row.shenhezhuangtai = 1
|
||||||
|
row.paixu = max(len(zones) - idx, 0)
|
||||||
|
row.save(update_fields=['tupian_url', 'shenhezhuangtai', 'paixu', 'UpdateTime'])
|
||||||
|
stats['zones_updated'] += 1
|
||||||
|
else:
|
||||||
|
row = ShangpinZhuanqu.query.create(
|
||||||
|
mingzi=name,
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
tupian_url=thumb or '',
|
||||||
|
shenhezhuangtai=1,
|
||||||
|
paixu=max(len(zones) - idx, 0),
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
stats['zones_created'] += 1
|
||||||
|
if src_id:
|
||||||
|
zone_id_map[src_id] = row.id
|
||||||
|
zone_id_map[name] = row.id
|
||||||
|
|
||||||
|
for p in products:
|
||||||
|
title = (p.get('title') or '').strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
zone_name = (p.get('zone_name') or p.get('target_zhuanqu_name') or '').strip()
|
||||||
|
cat2 = int(p.get('cat2_id') or 0)
|
||||||
|
zhuanqu_id = zone_id_map.get(cat2) or zone_id_map.get(zone_name)
|
||||||
|
if not zhuanqu_id:
|
||||||
|
self.stderr.write(f'跳过无专区商品: {title} zone={zone_name}')
|
||||||
|
continue
|
||||||
|
|
||||||
|
price = self._decimal(p.get('price'))
|
||||||
|
intro = (p.get('intro') or '').strip()
|
||||||
|
xuzhi = (p.get('buy_tips_text') or '').strip()
|
||||||
|
if p.get('rise_reason'):
|
||||||
|
xuzhi = ((xuzhi + '\n\n') if xuzhi else '') + f"加价说明:{p.get('rise_reason')}"
|
||||||
|
|
||||||
|
pics = list(p.get('pics_url') or [])
|
||||||
|
thumb = p.get('thumb_url') or (pics[0] if pics else '')
|
||||||
|
tupian = to_oss(thumb, 'shangpin/shangpintupian')
|
||||||
|
rule_imgs = list(p.get('rule_images_url') or [])
|
||||||
|
guize = to_oss(rule_imgs[0], 'shangpin/guize') if rule_imgs else ''
|
||||||
|
|
||||||
|
weigh = int(p.get('weigh') or p.get('home_weigh') or 0)
|
||||||
|
on_shelf = int(p.get('status') or 1) == 1
|
||||||
|
is_raffle = int(p.get('raffle_id') or 0) > 0
|
||||||
|
|
||||||
|
row = Shangpin.query.filter(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
biaoqian=title,
|
||||||
|
zhuanqu_id=zhuanqu_id,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
fields = dict(
|
||||||
|
biaoqian=title,
|
||||||
|
jiage=price,
|
||||||
|
kucun=99999,
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
zhuanqu_id=zhuanqu_id,
|
||||||
|
jieshao=intro,
|
||||||
|
xiadan_xuzhi=xuzhi[:5000] if xuzhi else '',
|
||||||
|
guize_tupian=guize or '',
|
||||||
|
tupian_url=tupian or '',
|
||||||
|
yaoqiuleixing=leixing.yaoqiuleixing,
|
||||||
|
huiyuan_id=leixing.huiyuan_id,
|
||||||
|
yongjin=leixing.yongjin,
|
||||||
|
paixu=weigh,
|
||||||
|
shenhezhuangtai=1,
|
||||||
|
shangjia_zhuangtai=on_shelf,
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
shi_zhuanpan=bool(is_raffle),
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if row:
|
||||||
|
for k, v in fields.items():
|
||||||
|
setattr(row, k, v)
|
||||||
|
row.save()
|
||||||
|
stats['products_updated'] += 1
|
||||||
|
else:
|
||||||
|
Shangpin.query.create(
|
||||||
|
zhenshi_xiaoliang=0,
|
||||||
|
duiwai_xiaoliang=0,
|
||||||
|
**fields,
|
||||||
|
)
|
||||||
|
stats['products_created'] += 1
|
||||||
|
|
||||||
|
if with_banners:
|
||||||
|
from config.models import Lunbo
|
||||||
|
from jituan.services.display_config import (
|
||||||
|
LUNBO_PAGE_ACCEPT_BANNER2,
|
||||||
|
LUNBO_PAGE_ACCEPT_ORDER,
|
||||||
|
lunbo_image_type_for_page,
|
||||||
|
)
|
||||||
|
|
||||||
|
def upsert_lunbo(page_key, items):
|
||||||
|
image_type = lunbo_image_type_for_page(page_key)
|
||||||
|
Lunbo.query.filter(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
page_key=page_key,
|
||||||
|
ImageType=image_type,
|
||||||
|
).delete()
|
||||||
|
for b in items:
|
||||||
|
url = to_oss(b.get('url') or '', 'a_long/lunbo')
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
Lunbo.query.create(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
page_key=page_key,
|
||||||
|
ImageURL=url,
|
||||||
|
ImageType=image_type,
|
||||||
|
)
|
||||||
|
stats['banners'] += 1
|
||||||
|
|
||||||
|
upsert_lunbo(LUNBO_PAGE_ACCEPT_ORDER, banners)
|
||||||
|
upsert_lunbo(LUNBO_PAGE_ACCEPT_BANNER2, banners2)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
'完成(仅 club=ufo): '
|
||||||
|
f"类型上架={stats['leixing_enabled']} "
|
||||||
|
f"专区+={stats['zones_created']}/改={stats['zones_updated']} "
|
||||||
|
f"商品+={stats['products_created']}/改={stats['products_updated']} "
|
||||||
|
f"轮播={stats['banners']} "
|
||||||
|
f"OSS成功={stats['oss_ok']} 失败={stats['oss_fail']}"
|
||||||
|
))
|
||||||
|
|
||||||
|
def _resolve_leixing(self, ShangpinLeixing, leixing_id: int, leixing_name: str):
|
||||||
|
"""优先 id;否则 jieshao 全名精确匹配且必须唯一。不做 icontains。"""
|
||||||
|
if leixing_id:
|
||||||
|
try:
|
||||||
|
lx = ShangpinLeixing.query.get(id=leixing_id)
|
||||||
|
except ShangpinLeixing.DoesNotExist as exc:
|
||||||
|
raise CommandError(f'类型 id={leixing_id} 不存在') from exc
|
||||||
|
return lx
|
||||||
|
|
||||||
|
name = (leixing_name or DEFAULT_LEIXING_NAME).strip() or DEFAULT_LEIXING_NAME
|
||||||
|
rows = list(
|
||||||
|
ShangpinLeixing.query.filter(jieshao=name)
|
||||||
|
.exclude(shenhezhuangtai=2)
|
||||||
|
.order_by('id')
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
near = list(
|
||||||
|
ShangpinLeixing.query.filter(jieshao__icontains=name).order_by('id')[:20]
|
||||||
|
)
|
||||||
|
hint = '\n'.join(
|
||||||
|
f' id={x.id} name={x.jieshao!r} shenhezhuangtai={x.shenhezhuangtai}'
|
||||||
|
for x in near
|
||||||
|
) or ' (无近似)'
|
||||||
|
raise CommandError(
|
||||||
|
f'找不到 jieshao 精确等于「{name}」且非审核专用的类型。库中近似:\n{hint}\n'
|
||||||
|
'请先在后台把类型名改成正好「三角洲」,或 --leixing-id=... 指定。'
|
||||||
|
)
|
||||||
|
if len(rows) > 1:
|
||||||
|
detail = '\n'.join(
|
||||||
|
f' id={x.id} name={x.jieshao!r} shenhezhuangtai={x.shenhezhuangtai}'
|
||||||
|
for x in rows
|
||||||
|
)
|
||||||
|
raise CommandError(
|
||||||
|
f'「{name}」精确匹配到多条,拒绝自动选,避免挂错:\n{detail}'
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decimal(v) -> Decimal:
|
||||||
|
try:
|
||||||
|
return Decimal(str(v if v is not None else '0'))
|
||||||
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
|
return Decimal('0')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _upload_remote(url: str, prefix: str) -> str:
|
||||||
|
"""返回 OSS 相对 key;失败返回空串。与后台一致:DB 只存相对路径。"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from utils.oss_utils import upload_to_oss
|
||||||
|
|
||||||
|
try:
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
req = Request(url, headers={'User-Agent': 'UFO-Import/1.0'})
|
||||||
|
with urlopen(req, timeout=60, context=ctx) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
ctype = resp.headers.get('Content-Type') or ''
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return ''
|
||||||
|
|
||||||
|
ext = mimetypes.guess_extension((ctype or '').split(';')[0].strip()) or ''
|
||||||
|
if not ext or ext == '.jpe':
|
||||||
|
m = re.search(r'\.(jpg|jpeg|png|gif|webp|bmp)(?:\?|$)', url, re.I)
|
||||||
|
ext = ('.' + m.group(1).lower()) if m else '.jpg'
|
||||||
|
if ext == '.jpeg':
|
||||||
|
ext = '.jpg'
|
||||||
|
|
||||||
|
ts = datetime.now().strftime('%Y%m%d%H%M%S')
|
||||||
|
rel = f"{prefix.strip('/')}/{ts}_{uuid.uuid4().hex[:8]}{ext}"
|
||||||
|
bio = BytesIO(raw)
|
||||||
|
bio.name = os.path.basename(rel)
|
||||||
|
ok = upload_to_oss(bio, rel)
|
||||||
|
if not ok:
|
||||||
|
return ''
|
||||||
|
return rel # 关键:只返回相对路径,不返回完整 CDN URL
|
||||||
312
jituan/management/commands/reset_lxs_shenhe_catalog_from_xq.py
Normal file
312
jituan/management/commands/reset_lxs_shenhe_catalog_from_xq.py
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
龙先生(lxs)审核版目录重置:清空本俱乐部三个审核类型下的专区/商品,再从星阙(xq)原样复制。
|
||||||
|
|
||||||
|
范围(必须全部满足):
|
||||||
|
- 目标俱乐部固定 lxs(name 含「龙先生」)
|
||||||
|
- 源俱乐部固定 xq(name 含「星阙」)
|
||||||
|
- 仅全局 ShangpinLeixing.shenhezhuangtai=2 的类型(通常 3 个)
|
||||||
|
- 仅公共目录 dianpu IS NULL
|
||||||
|
- 专区/商品均要求 shenhezhuangtai=2
|
||||||
|
|
||||||
|
步骤:
|
||||||
|
1) 删除 lxs 下上述类型的专区+商品(只删 DB,不删 OSS,避免误伤星阙共用图)
|
||||||
|
2) 从 xq 复制同类型专区+商品到 lxs(图片复用相对路径;销量归零)
|
||||||
|
|
||||||
|
不碰:正常商品(shenhezhuangtai=1)、其它俱乐部、店铺商品、全局类型表、订单等。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python manage.py reset_lxs_shenhe_catalog_from_xq
|
||||||
|
python manage.py reset_lxs_shenhe_catalog_from_xq --list
|
||||||
|
python manage.py reset_lxs_shenhe_catalog_from_xq --apply --i-confirm=lxs-from-xq
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
TARGET_CLUB = 'lxs'
|
||||||
|
TARGET_NAME_HINT = '龙先生'
|
||||||
|
SOURCE_CLUB = 'xq'
|
||||||
|
SOURCE_NAME_HINT = '星阙'
|
||||||
|
SHENHE = 2
|
||||||
|
CONFIRM_TOKEN = 'lxs-from-xq'
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '清空龙先生审核专区/商品,并从星阙复制三个审核类型目录(默认 dry-run)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--apply', action='store_true', help='真正删+写')
|
||||||
|
parser.add_argument(
|
||||||
|
'--i-confirm',
|
||||||
|
type=str,
|
||||||
|
default='',
|
||||||
|
help=f'必须等于 {CONFIRM_TOKEN} 才允许 --apply',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--list',
|
||||||
|
action='store_true',
|
||||||
|
help='只列出审核类型及 xq/lxs 下专区商品数量',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from jituan.models import Club
|
||||||
|
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||||
|
|
||||||
|
apply = bool(options.get('apply'))
|
||||||
|
confirm = (options.get('i_confirm') or '').strip()
|
||||||
|
|
||||||
|
src_club = Club.query.filter(club_id=SOURCE_CLUB).first()
|
||||||
|
if not src_club:
|
||||||
|
raise CommandError(f'源俱乐部 {SOURCE_CLUB} 不存在')
|
||||||
|
if SOURCE_NAME_HINT not in (src_club.name or ''):
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:{SOURCE_CLUB} 的 name={src_club.name!r} 不含「{SOURCE_NAME_HINT}」'
|
||||||
|
)
|
||||||
|
|
||||||
|
dst_club = Club.query.filter(club_id=TARGET_CLUB).first()
|
||||||
|
if not dst_club:
|
||||||
|
raise CommandError(f'目标俱乐部 {TARGET_CLUB} 不存在')
|
||||||
|
if TARGET_NAME_HINT not in (dst_club.name or ''):
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:{TARGET_CLUB} 的 name={dst_club.name!r} 不含「{TARGET_NAME_HINT}」'
|
||||||
|
)
|
||||||
|
|
||||||
|
audit_types = list(
|
||||||
|
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
|
||||||
|
)
|
||||||
|
if not audit_types:
|
||||||
|
raise CommandError('没有任何审核专用商品类型(shenhezhuangtai=2)')
|
||||||
|
if len(audit_types) != 3:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
f'注意:当前审核类型数量={len(audit_types)}(预期常为 3),将按实际全部处理。'
|
||||||
|
))
|
||||||
|
|
||||||
|
leixing_ids = [lx.id for lx in audit_types]
|
||||||
|
|
||||||
|
def counts(club_id):
|
||||||
|
z = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=club_id,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
p = Shangpin.query.filter(
|
||||||
|
club_id=club_id,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
return z, p
|
||||||
|
|
||||||
|
def per_type(club_id):
|
||||||
|
rows = []
|
||||||
|
for lx in audit_types:
|
||||||
|
z = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=club_id,
|
||||||
|
leixing_id=lx.id,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
p = Shangpin.query.filter(
|
||||||
|
club_id=club_id,
|
||||||
|
leixing_id=lx.id,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
rows.append((lx, z, p))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
if options.get('list'):
|
||||||
|
self.stdout.write(self.style.NOTICE('审核类型及两侧存量:'))
|
||||||
|
for lx in audit_types:
|
||||||
|
sz, sp = (
|
||||||
|
ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=SOURCE_CLUB, leixing_id=lx.id,
|
||||||
|
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||||
|
).count(),
|
||||||
|
Shangpin.query.filter(
|
||||||
|
club_id=SOURCE_CLUB, leixing_id=lx.id,
|
||||||
|
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
dz, dp = (
|
||||||
|
ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=TARGET_CLUB, leixing_id=lx.id,
|
||||||
|
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||||
|
).count(),
|
||||||
|
Shangpin.query.filter(
|
||||||
|
club_id=TARGET_CLUB, leixing_id=lx.id,
|
||||||
|
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||||
|
).count(),
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f' id={lx.id}「{lx.jieshao}」 '
|
||||||
|
f'xq: zones={sz} products={sp} | '
|
||||||
|
f'lxs: zones={dz} products={dp}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if apply and confirm != CONFIRM_TOKEN:
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:--apply 必须带 --i-confirm={CONFIRM_TOKEN}\n'
|
||||||
|
f' python manage.py reset_lxs_shenhe_catalog_from_xq '
|
||||||
|
f'--apply --i-confirm={CONFIRM_TOKEN}'
|
||||||
|
)
|
||||||
|
|
||||||
|
src_z_total, src_p_total = counts(SOURCE_CLUB)
|
||||||
|
dst_z_total, dst_p_total = counts(TARGET_CLUB)
|
||||||
|
|
||||||
|
src_zones = list(
|
||||||
|
ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=SOURCE_CLUB,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).order_by('leixing_id', 'paixu', 'id')
|
||||||
|
)
|
||||||
|
src_products = list(
|
||||||
|
Shangpin.query.filter(
|
||||||
|
club_id=SOURCE_CLUB,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).order_by('leixing_id', 'paixu', 'id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# 源商品的专区必须落在本批源专区内,否则拒绝(防串数据)
|
||||||
|
src_zone_ids = {z.id for z in src_zones}
|
||||||
|
orphan = [p for p in src_products if p.zhuanqu_id not in src_zone_ids]
|
||||||
|
if orphan:
|
||||||
|
sample = ', '.join(f'{p.id}:{p.biaoqian}' for p in orphan[:5])
|
||||||
|
raise CommandError(
|
||||||
|
f'源商品有 {len(orphan)} 条专区不在本批审核专区内,已中止。样例: {sample}'
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
f'\n===== {mode} 龙先生审核目录 ← 星阙 =====\n'
|
||||||
|
f'源: {SOURCE_CLUB} ({src_club.name}) '
|
||||||
|
f'专区={src_z_total} 商品={src_p_total}\n'
|
||||||
|
f'目标: {TARGET_CLUB} ({dst_club.name}) '
|
||||||
|
f'将先删除 专区={dst_z_total} 商品={dst_p_total},再复制源数据\n'
|
||||||
|
f'审核类型数={len(audit_types)} ids={leixing_ids}\n'
|
||||||
|
f'OSS: 不删文件;复制时复用星阙相对路径\n'
|
||||||
|
))
|
||||||
|
|
||||||
|
self.stdout.write('—— 分类型存量 ——')
|
||||||
|
for lx, sz, sp in per_type(SOURCE_CLUB):
|
||||||
|
self.stdout.write(f' [xq] id={lx.id}「{lx.jieshao}」 zones={sz} products={sp}')
|
||||||
|
for lx, dz, dp in per_type(TARGET_CLUB):
|
||||||
|
self.stdout.write(f' [lxs] id={lx.id}「{lx.jieshao}」 zones={dz} products={dp}')
|
||||||
|
|
||||||
|
self.stdout.write('—— 将从 xq 复制的专区 ——')
|
||||||
|
for z in src_zones[:40]:
|
||||||
|
self.stdout.write(
|
||||||
|
f' zq#{z.id} leixing={z.leixing_id} {z.mingzi!r}'
|
||||||
|
)
|
||||||
|
if len(src_zones) > 40:
|
||||||
|
self.stdout.write(f' ... 其余 {len(src_zones) - 40} 个省略')
|
||||||
|
|
||||||
|
self.stdout.write('—— 将从 xq 复制的商品样例 ——')
|
||||||
|
for p in src_products[:15]:
|
||||||
|
self.stdout.write(
|
||||||
|
f' sp#{p.id} leixing={p.leixing_id} {p.biaoqian!r} ¥{p.jiage}'
|
||||||
|
)
|
||||||
|
if len(src_products) > 15:
|
||||||
|
self.stdout.write(f' ... 其余 {len(src_products) - 15} 个省略')
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'\n以上仅计划:未删库、未写库。确认无误后:\n'
|
||||||
|
f' python manage.py reset_lxs_shenhe_catalog_from_xq '
|
||||||
|
f'--apply --i-confirm={CONFIRM_TOKEN}\n'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'deleted_products': 0,
|
||||||
|
'deleted_zones': 0,
|
||||||
|
'zones_created': 0,
|
||||||
|
'products_created': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
# 1) 只删 lxs + 审核类型 + 公共
|
||||||
|
n_prod, _ = Shangpin.query.filter(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).delete()
|
||||||
|
n_zone, _ = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).delete()
|
||||||
|
stats['deleted_products'] = n_prod
|
||||||
|
stats['deleted_zones'] = n_zone
|
||||||
|
|
||||||
|
# 2) 按源专区插入,建立 id 映射
|
||||||
|
zone_id_map = {}
|
||||||
|
for z in src_zones:
|
||||||
|
if z.leixing_id not in leixing_ids:
|
||||||
|
raise CommandError(f'专区 {z.id} 类型 {z.leixing_id} 不在审核列表')
|
||||||
|
row = ShangpinZhuanqu.query.create(
|
||||||
|
mingzi=z.mingzi,
|
||||||
|
leixing_id=z.leixing_id,
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
tupian_url=z.tupian_url or '',
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
paixu=int(z.paixu or 0),
|
||||||
|
shangjia_zhuangtai=bool(getattr(z, 'shangjia_zhuangtai', True)),
|
||||||
|
fengjin_zhuangtai=bool(getattr(z, 'fengjin_zhuangtai', True)),
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
zone_id_map[z.id] = row.id
|
||||||
|
stats['zones_created'] += 1
|
||||||
|
|
||||||
|
for p in src_products:
|
||||||
|
if p.leixing_id not in leixing_ids:
|
||||||
|
raise CommandError(f'商品 {p.id} 类型 {p.leixing_id} 不在审核列表')
|
||||||
|
dst_zq = zone_id_map.get(p.zhuanqu_id)
|
||||||
|
if not dst_zq:
|
||||||
|
raise CommandError(
|
||||||
|
f'商品「{p.biaoqian}」id={p.id} 无法映射 zhuanqu_id={p.zhuanqu_id}'
|
||||||
|
)
|
||||||
|
Shangpin.query.create(
|
||||||
|
biaoqian=p.biaoqian,
|
||||||
|
jiage=p.jiage,
|
||||||
|
kucun=p.kucun,
|
||||||
|
leixing_id=p.leixing_id,
|
||||||
|
zhuanqu_id=dst_zq,
|
||||||
|
zhenshi_xiaoliang=0,
|
||||||
|
duiwai_xiaoliang=0,
|
||||||
|
jieshao=p.jieshao,
|
||||||
|
xiadan_xuzhi=p.xiadan_xuzhi,
|
||||||
|
guize_tupian=p.guize_tupian or '',
|
||||||
|
tupian_url=p.tupian_url or '',
|
||||||
|
yaoqiuleixing=p.yaoqiuleixing,
|
||||||
|
huiyuan_id=p.huiyuan_id,
|
||||||
|
yongjin=p.yongjin,
|
||||||
|
kaioi_ewai_dashou_fencheng=bool(p.kaioi_ewai_dashou_fencheng),
|
||||||
|
ewai_dashou_fencheng=p.ewai_dashou_fencheng,
|
||||||
|
paixu=int(p.paixu or 0),
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
shangjia_zhuangtai=bool(p.shangjia_zhuangtai),
|
||||||
|
fengjin_zhuangtai=bool(p.fengjin_zhuangtai),
|
||||||
|
shenhe_zhuangtai=bool(getattr(p, 'shenhe_zhuangtai', True)),
|
||||||
|
club_id=TARGET_CLUB,
|
||||||
|
shi_zhuanpan=False,
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
stats['products_created'] += 1
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'\n完成: {stats}\n'
|
||||||
|
f'星阙(xq)未改动;龙先生(lxs)审核目录已与星阙对齐。'
|
||||||
|
))
|
||||||
342
jituan/management/commands/reset_lxs_shenhe_esports_coach.py
Normal file
342
jituan/management/commands/reset_lxs_shenhe_esports_coach.py
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
龙先生(lxs)审核版目录重置:对齐小程序简介的「专业电竞护航 / 技巧支持 / 策略指导」。
|
||||||
|
|
||||||
|
目标(审核口径):
|
||||||
|
- 强调:线上电竞技巧讲解、对局策略交流、团队协作指导、正向技能提升
|
||||||
|
- 绝不出现:代练、代打、上分、线下陪玩、陪玩约玩等
|
||||||
|
- 仅改 club_id=lxs + 全局审核类型(shenhezhuangtai=2) 下的专区/商品
|
||||||
|
- 不碰正常商品(shenhezhuangtai=1)、其它俱乐部、店铺商品、订单
|
||||||
|
|
||||||
|
动作:
|
||||||
|
1) 将 3 个审核类型名改为:电竞技巧指导 / 对局策略交流 / 线上技能提升
|
||||||
|
2) 清空 lxs 下这些类型的审核专区+商品(只删 DB,不删 OSS)
|
||||||
|
3) 每个类型 2 个专区;每个专区 10 个商品(共 60)
|
||||||
|
4) 图标统一 a_long/morentouxiang.jpg
|
||||||
|
5) 下单须知写明:仅线上指导交流,禁止代练代打与线下服务
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python manage.py reset_lxs_shenhe_esports_coach
|
||||||
|
python manage.py reset_lxs_shenhe_esports_coach --list
|
||||||
|
python manage.py reset_lxs_shenhe_esports_coach --apply --i-confirm=lxs-esports-coach
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
ALLOWED_CLUB_ID = 'lxs'
|
||||||
|
NAME_HINT = '龙先生'
|
||||||
|
SHENHE = 2
|
||||||
|
DEFAULT_AVATAR = 'a_long/morentouxiang.jpg'
|
||||||
|
CONFIRM_TOKEN = 'lxs-esports-coach'
|
||||||
|
EXPECTED_TYPE_COUNT = 3
|
||||||
|
|
||||||
|
TYPE_NAMES = ['电竞技巧指导', '对局策略交流', '线上技能提升']
|
||||||
|
|
||||||
|
ZONE_NAMES_BY_TYPE = [
|
||||||
|
['技巧讲解', '操作指导'],
|
||||||
|
['策略复盘', '思路交流'],
|
||||||
|
['协作训练', '正向成长'],
|
||||||
|
]
|
||||||
|
|
||||||
|
PRODUCTS_PER_ZONE = [
|
||||||
|
[
|
||||||
|
('基础操作技巧讲解', '线上讲解基础操作与键位思路,帮助建立正确习惯,重在理解与练习。', '19.90'),
|
||||||
|
('走位思路实时指导', '对局中讲解走位与观察习惯,提升意识,由学员本人操作完成。', '25.90'),
|
||||||
|
('技能连招要点拆解', '拆解技能释放节奏与连招要点,循序渐进,强调自学自练。', '22.90'),
|
||||||
|
('地图资源意识教学', '讲解地图信息与资源节奏,培养全局观,纯技巧交流。', '22.90'),
|
||||||
|
('瞄准与预判要点分享', '分享瞄准预判的练习方法,给出训练建议,学员自行练习。', '25.90'),
|
||||||
|
('开局节奏规划指导', '讲解开局规划与信息收集,帮助形成稳定开局习惯。', '19.90'),
|
||||||
|
('道具使用思路讲解', '讲解常用道具使用时机与配合思路,强调理解而非包办。', '19.90'),
|
||||||
|
('视野与信息读取教学', '教学如何读图、读信息,提升判断力,正向技能成长。', '22.90'),
|
||||||
|
('残局处理要点交流', '交流残局决策思路与优先级,鼓励独立思考与复盘。', '25.90'),
|
||||||
|
('个性化技巧答疑', '针对个人薄弱点线上答疑,给出可执行的练习清单。', '29.90'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
('对局策略思路交流', '线上交流阵容与战术思路,重在理解策略,学员自主决策执行。', '25.90'),
|
||||||
|
('团战站位策略讲解', '讲解团战站位与目标优先级,提升团队配合意识。', '25.90'),
|
||||||
|
('资源分配策略讨论', '讨论资源分配与节奏取舍,培养理性决策能力。', '22.90'),
|
||||||
|
('对手习惯观察复盘', '复盘如何观察对手习惯,给出分析框架,鼓励自我复盘。', '22.90'),
|
||||||
|
('中期转线策略交流', '交流中期转线与节奏切换思路,帮助建立阶段目标。', '25.90'),
|
||||||
|
('防守反击策略拆解', '拆解防守反击的触发条件与执行步骤,逻辑清晰可练。', '22.90'),
|
||||||
|
('进攻时机判断指导', '讲解进攻时机判断要素,强调信息与纪律,杜绝侥幸。', '25.90'),
|
||||||
|
('阵容克制关系科普', '科普常见克制关系与应对思路,开阔视野,正向学习。', '19.90'),
|
||||||
|
('赛后复盘方法教学', '教学赛后复盘步骤:录像、标注、总结,养成成长闭环。', '19.90'),
|
||||||
|
('策略问答一对一', '一对一策略问答,解答困惑,给出清晰可落地的改进方向。', '29.90'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
('团队沟通协作指导', '指导语音沟通话术与分工习惯,打造文明友好的协作环境。', '19.90'),
|
||||||
|
('良性竞争心态辅导', '交流输赢心态与压力管理,传递体育精神与正能量。', '15.90'),
|
||||||
|
('训练计划制定协助', '协助制定可坚持的训练计划,目标清晰、循序渐进。', '19.90'),
|
||||||
|
('团队默契练习指导', '指导信号、分工与节奏同步练习方法,提升协作效率。', '22.90'),
|
||||||
|
('文明竞技礼仪分享', '分享线上竞技礼仪与友好互动规范,拒绝不良言行。', '12.90'),
|
||||||
|
('专注力与节奏管理', '交流专注力与作息节奏管理小方法,支持长期进步。', '15.90'),
|
||||||
|
('新手友好入门指导', '面向新手的入门讲解与练习指导,学员全程自主操作。', '19.90'),
|
||||||
|
('进阶目标拆解辅导', '把长期目标拆成周计划,每周复盘一次,稳步提升。', '22.90'),
|
||||||
|
('录像点评成长课', '基于学员录像做要点点评,指出亮点与改进点,鼓励成长。', '29.90'),
|
||||||
|
('电竞护航技巧总复习', '汇总技巧与策略要点,形成个人笔记,巩固线上学习成果。', '25.90'),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
BANNED_WORDS = (
|
||||||
|
'代练', '代打', '上分', '上段', '包星', '定位赛', '代肝', '代刷',
|
||||||
|
'代打手', '代练平台', '代练服务', '护航上分', '包赢',
|
||||||
|
'陪玩', '线下陪玩', '上门', '约玩', '陪睡觉', '色情',
|
||||||
|
'赌博', '出租号', '买号', '卖号',
|
||||||
|
)
|
||||||
|
|
||||||
|
ORDER_NOTICE = (
|
||||||
|
'本服务为龙先生电竞提供的线上电竞技巧支持、对局策略指导与正向技能交流。'
|
||||||
|
'全程由学员本人登录并亲自操作、自行决策;服务内容仅限讲解、答疑与思路交流。'
|
||||||
|
'请文明沟通,共建专业、健康的电竞学习环境。'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '龙先生审核版改为电竞技巧/策略指导正能量目录(默认 dry-run)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--apply', action='store_true', help='真正写库')
|
||||||
|
parser.add_argument(
|
||||||
|
'--i-confirm',
|
||||||
|
type=str,
|
||||||
|
default='',
|
||||||
|
help=f'必须等于 {CONFIRM_TOKEN} 才允许 --apply',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--list',
|
||||||
|
action='store_true',
|
||||||
|
help='只列出当前审核类型及 lxs 存量',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--skip-rename-types',
|
||||||
|
action='store_true',
|
||||||
|
help='不修改全局审核类型名称,仅重建 lxs 专区/商品',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from jituan.models import Club
|
||||||
|
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||||
|
|
||||||
|
apply = bool(options.get('apply'))
|
||||||
|
confirm = (options.get('i_confirm') or '').strip()
|
||||||
|
skip_rename = bool(options.get('skip_rename_types'))
|
||||||
|
|
||||||
|
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||||
|
if not club:
|
||||||
|
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
|
||||||
|
if NAME_HINT not in (club.name or ''):
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:{ALLOWED_CLUB_ID} name={club.name!r} 不含「{NAME_HINT}」'
|
||||||
|
)
|
||||||
|
|
||||||
|
audit_types = list(
|
||||||
|
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
|
||||||
|
)
|
||||||
|
if len(audit_types) != EXPECTED_TYPE_COUNT:
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:审核类型数量应为 {EXPECTED_TYPE_COUNT},'
|
||||||
|
f'当前为 {len(audit_types)}:'
|
||||||
|
+ ', '.join(f'id={t.id}「{t.jieshao}」' for t in audit_types)
|
||||||
|
)
|
||||||
|
|
||||||
|
leixing_ids = [t.id for t in audit_types]
|
||||||
|
|
||||||
|
def counts():
|
||||||
|
z = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
p = Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
return z, p
|
||||||
|
|
||||||
|
if options.get('list'):
|
||||||
|
self.stdout.write(self.style.NOTICE('审核类型 → 计划改名;lxs 当前专区/商品:'))
|
||||||
|
for i, t in enumerate(audit_types):
|
||||||
|
z = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID, leixing_id=t.id,
|
||||||
|
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
p = Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID, leixing_id=t.id,
|
||||||
|
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
self.stdout.write(
|
||||||
|
f' id={t.id}「{t.jieshao}」→「{TYPE_NAMES[i]}」 '
|
||||||
|
f'lxs zones={z} products={p}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for zone_list in PRODUCTS_PER_ZONE:
|
||||||
|
if len(zone_list) != 10:
|
||||||
|
raise CommandError('内部错误:每个类型必须恰好 10 个商品模板')
|
||||||
|
for title, intro, _price in zone_list:
|
||||||
|
# 只扫标题/介绍;须知用正向表述,不出现敏感词本身
|
||||||
|
blob = title + intro
|
||||||
|
for w in BANNED_WORDS:
|
||||||
|
if w in blob:
|
||||||
|
raise CommandError(f'文案含禁止词「{w}」: {title}')
|
||||||
|
for w in BANNED_WORDS:
|
||||||
|
if w in ORDER_NOTICE:
|
||||||
|
raise CommandError(f'下单须知含禁止词「{w}」,请改成正向表述')
|
||||||
|
|
||||||
|
for zones in ZONE_NAMES_BY_TYPE:
|
||||||
|
if len(zones) != 2:
|
||||||
|
raise CommandError('内部错误:每个类型必须恰好 2 个专区')
|
||||||
|
for zn in zones:
|
||||||
|
for w in BANNED_WORDS:
|
||||||
|
if w in zn:
|
||||||
|
raise CommandError(f'专区名含禁止词「{w}」: {zn}')
|
||||||
|
|
||||||
|
for tn in TYPE_NAMES:
|
||||||
|
for w in BANNED_WORDS:
|
||||||
|
if w in tn:
|
||||||
|
raise CommandError(f'类型名含禁止词「{w}」: {tn}')
|
||||||
|
|
||||||
|
if apply and confirm != CONFIRM_TOKEN:
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:--apply 必须带 --i-confirm={CONFIRM_TOKEN}\n'
|
||||||
|
f' python manage.py reset_lxs_shenhe_esports_coach '
|
||||||
|
f'--apply --i-confirm={CONFIRM_TOKEN}'
|
||||||
|
)
|
||||||
|
|
||||||
|
old_z, old_p = counts()
|
||||||
|
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
f'\n===== {mode} 龙先生审核 → 电竞技巧/策略指导 =====\n'
|
||||||
|
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
|
||||||
|
f'将清空 lxs 审核专区={old_z} 商品={old_p},再写入 '
|
||||||
|
f'{EXPECTED_TYPE_COUNT}类型×2专区×10商品=60\n'
|
||||||
|
f'图标: {DEFAULT_AVATAR}\n'
|
||||||
|
f'改名全局类型: {"否" if skip_rename else "是(影响所有俱乐部审核展示名)"}\n'
|
||||||
|
f'定位: 线上技巧支持 / 策略指导 / 正向技能提升;无代练代打、无线下陪玩\n'
|
||||||
|
))
|
||||||
|
|
||||||
|
self.stdout.write('—— 类型改名计划 ——')
|
||||||
|
for i, t in enumerate(audit_types):
|
||||||
|
self.stdout.write(f' id={t.id}「{t.jieshao}」→「{TYPE_NAMES[i]}」')
|
||||||
|
|
||||||
|
self.stdout.write('—— 专区/商品结构 ——')
|
||||||
|
for i, tname in enumerate(TYPE_NAMES):
|
||||||
|
for zn in ZONE_NAMES_BY_TYPE[i]:
|
||||||
|
self.stdout.write(f' 「{tname}」/「{zn}」→ 10 个指导类商品')
|
||||||
|
|
||||||
|
self.stdout.write('—— 商品样例 ——')
|
||||||
|
for title, intro, price in PRODUCTS_PER_ZONE[0][:3]:
|
||||||
|
self.stdout.write(f' {title} ¥{price} | {intro}')
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'\n以上仅计划。确认后执行:\n'
|
||||||
|
f' python manage.py reset_lxs_shenhe_esports_coach '
|
||||||
|
f'--apply --i-confirm={CONFIRM_TOKEN}\n'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'types_renamed': 0,
|
||||||
|
'deleted_products': 0,
|
||||||
|
'deleted_zones': 0,
|
||||||
|
'zones_created': 0,
|
||||||
|
'products_created': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
if not skip_rename:
|
||||||
|
for i, t in enumerate(audit_types):
|
||||||
|
new_name = TYPE_NAMES[i]
|
||||||
|
if (t.jieshao or '') != new_name:
|
||||||
|
t.jieshao = new_name
|
||||||
|
t.tupian_url = t.tupian_url or DEFAULT_AVATAR
|
||||||
|
t.save()
|
||||||
|
stats['types_renamed'] += 1
|
||||||
|
|
||||||
|
n_prod, _ = Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).delete()
|
||||||
|
n_zone, _ = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).delete()
|
||||||
|
stats['deleted_products'] = n_prod
|
||||||
|
stats['deleted_zones'] = n_zone
|
||||||
|
|
||||||
|
for i, lx in enumerate(audit_types):
|
||||||
|
lx = ShangpinLeixing.query.filter(id=lx.id).first()
|
||||||
|
zone_names = ZONE_NAMES_BY_TYPE[i]
|
||||||
|
base_products = PRODUCTS_PER_ZONE[i]
|
||||||
|
for zi, zname in enumerate(zone_names):
|
||||||
|
zrow = ShangpinZhuanqu.query.create(
|
||||||
|
mingzi=zname,
|
||||||
|
leixing_id=lx.id,
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
tupian_url=DEFAULT_AVATAR,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
paixu=20 - zi * 10,
|
||||||
|
shangjia_zhuangtai=True,
|
||||||
|
fengjin_zhuangtai=True,
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
stats['zones_created'] += 1
|
||||||
|
|
||||||
|
for pi, (title, intro, price) in enumerate(base_products):
|
||||||
|
full_title = f'{title}·{zname}'
|
||||||
|
for w in BANNED_WORDS:
|
||||||
|
if w in full_title or w in intro:
|
||||||
|
raise CommandError(f'禁止词命中: {full_title}')
|
||||||
|
Shangpin.query.create(
|
||||||
|
biaoqian=full_title[:45],
|
||||||
|
jiage=Decimal(price),
|
||||||
|
kucun=99999,
|
||||||
|
leixing_id=lx.id,
|
||||||
|
zhuanqu_id=zrow.id,
|
||||||
|
zhenshi_xiaoliang=0,
|
||||||
|
duiwai_xiaoliang=0,
|
||||||
|
jieshao=intro,
|
||||||
|
xiadan_xuzhi=ORDER_NOTICE,
|
||||||
|
guize_tupian='',
|
||||||
|
tupian_url=DEFAULT_AVATAR,
|
||||||
|
yaoqiuleixing=lx.yaoqiuleixing,
|
||||||
|
huiyuan_id=lx.huiyuan_id,
|
||||||
|
yongjin=lx.yongjin,
|
||||||
|
kaioi_ewai_dashou_fencheng=False,
|
||||||
|
ewai_dashou_fencheng=None,
|
||||||
|
paixu=100 - pi,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
shangjia_zhuangtai=True,
|
||||||
|
fengjin_zhuangtai=True,
|
||||||
|
shenhe_zhuangtai=True,
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
shi_zhuanpan=False,
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
stats['products_created'] += 1
|
||||||
|
|
||||||
|
if stats['zones_created'] != 6 or stats['products_created'] != 60:
|
||||||
|
raise CommandError(
|
||||||
|
f'数量异常: zones={stats["zones_created"]} '
|
||||||
|
f'products={stats["products_created"]},期望 6/60,已回滚'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'\n完成: {stats}\n'
|
||||||
|
f'均为审核专用(shenhezhuangtai=2);文案对齐「技巧支持/策略指导」,'
|
||||||
|
f'已避开代练代打与线下陪玩。'
|
||||||
|
))
|
||||||
332
jituan/management/commands/reset_lxs_shenhe_yule_peiban.py
Normal file
332
jituan/management/commands/reset_lxs_shenhe_yule_peiban.py
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
龙先生(lxs)审核目录重置为「娱乐陪伴」正向内容(非代练代打、不过度游戏化)。
|
||||||
|
|
||||||
|
针对:全局 3 个 shenhezhuangtai=2 的商品类型 + 俱乐部 lxs 下专区/商品。
|
||||||
|
|
||||||
|
动作:
|
||||||
|
1) 将 3 个审核类型名改为:娱乐陪伴 / 休闲同行 / 友好互动(改全局类型名,dry-run 会提示)
|
||||||
|
2) 清空 lxs 下这些类型的审核专区+商品(只删 DB,不删 OSS)
|
||||||
|
3) 每个类型建 2 个专区;每个专区 10 个商品(共 60)
|
||||||
|
4) 图标一律 a_long/morentouxiang.jpg
|
||||||
|
5) 文案纯正能量陪伴/交流,禁止代练代打上分等词
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python manage.py reset_lxs_shenhe_yule_peiban
|
||||||
|
python manage.py reset_lxs_shenhe_yule_peiban --list
|
||||||
|
python manage.py reset_lxs_shenhe_yule_peiban --apply --i-confirm=lxs-yule-peiban
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
ALLOWED_CLUB_ID = 'lxs'
|
||||||
|
NAME_HINT = '龙先生'
|
||||||
|
SHENHE = 2
|
||||||
|
DEFAULT_AVATAR = 'a_long/morentouxiang.jpg'
|
||||||
|
CONFIRM_TOKEN = 'lxs-yule-peiban'
|
||||||
|
EXPECTED_TYPE_COUNT = 3
|
||||||
|
|
||||||
|
# 三个审核类型目标名称(按现有审核类型 id 升序一一对应)
|
||||||
|
TYPE_NAMES = ['娱乐陪伴', '休闲同行', '友好互动']
|
||||||
|
|
||||||
|
# 每个类型 2 个专区名
|
||||||
|
ZONE_NAMES_BY_TYPE = [
|
||||||
|
['温暖陪伴', '轻松畅聊'],
|
||||||
|
['兴趣交流', '日常鼓励'],
|
||||||
|
['正能量同行', '休闲时光'],
|
||||||
|
]
|
||||||
|
|
||||||
|
# 每个专区 10 个商品:(标题, 介绍, 价格)
|
||||||
|
# 刻意弱化具体竞技/上分,强调陪伴、交流、正向娱乐
|
||||||
|
PRODUCTS_PER_ZONE = [
|
||||||
|
[
|
||||||
|
('暖心语音陪伴', '友好语音交流,倾听分享,营造轻松正能量氛围。', '19.90'),
|
||||||
|
('轻松心情疏导', '温和聊天陪伴,帮助放松心情,传递积极态度。', '19.90'),
|
||||||
|
('兴趣话题畅聊', '围绕健康兴趣爱好交流,互相启发,气氛友善。', '15.90'),
|
||||||
|
('正能量晚间陪伴', '晚间轻松聊天,分享日常点滴,友好互动。', '19.90'),
|
||||||
|
('学习打卡互勉', '互相提醒学习计划,鼓励坚持,共同进步。', '15.90'),
|
||||||
|
('运动打卡陪伴', '交流运动习惯与打卡心得,互相鼓励坚持。', '15.90'),
|
||||||
|
('阅读心得分享', '分享好书与阅读感受,文明交流,开阔视野。', '12.90'),
|
||||||
|
('音乐放松同行', '一起听歌聊天放松,分享喜爱曲目,气氛轻松。', '12.90'),
|
||||||
|
('生活小妙招交流', '交流生活经验与小技巧,互助友爱。', '12.90'),
|
||||||
|
('周末休闲畅聊', '周末轻松闲聊,分享愉快见闻,友好陪伴。', '15.90'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
('兴趣社群式交流', '按兴趣轻松交流,结识同好,氛围文明友好。', '15.90'),
|
||||||
|
('手工创作分享', '分享手工与创作过程,互相欣赏鼓励。', '15.90'),
|
||||||
|
('摄影见闻交流', '交流摄影与风景见闻,传递美好感受。', '12.90'),
|
||||||
|
('厨艺心得闲聊', '聊聊家常菜与烹饪心得,轻松愉快。', '12.90'),
|
||||||
|
('宠物日常分享', '分享萌宠日常,友好互动,温暖治愈。', '12.90'),
|
||||||
|
('旅行见闻讲述', '讲述旅行见闻与风景,开阔眼界,正能量满满。', '19.90'),
|
||||||
|
('电影观后闲谈', '交流观影感受与推荐,文明讨论,轻松愉快。', '12.90'),
|
||||||
|
('桌游规则解说', '友好讲解休闲桌游玩法,一起快乐体验。', '15.90'),
|
||||||
|
('棋类休闲对弈', '休闲棋类友好对弈,重在交流与乐趣。', '15.90'),
|
||||||
|
('午后茶歇闲聊', '午后轻松聊天陪伴,舒缓节奏,气氛温馨。', '12.90'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
('早安鼓励打卡', '清晨互相问候鼓励,开启积极一天。', '9.90'),
|
||||||
|
('习惯养成互勉', '一起制定小目标,互相督促,温柔坚持。', '15.90'),
|
||||||
|
('情绪友好倾听', '认真倾听烦恼,给予友善支持与正向建议。', '19.90'),
|
||||||
|
('时间管理小谈', '交流高效作息小方法,互相提醒,积极向上。', '15.90'),
|
||||||
|
('环保生活分享', '分享绿色生活小习惯,传递责任与温暖。', '9.90'),
|
||||||
|
('志愿服务见闻', '交流公益与志愿服务见闻,弘扬正能量。', '12.90'),
|
||||||
|
('亲子沟通小贴士', '交流亲子相处经验,友善互助(非医疗建议)。', '15.90'),
|
||||||
|
('职场礼仪闲谈', '聊聊职场沟通礼仪与礼貌习惯,积极文明。', '15.90'),
|
||||||
|
('节日祝福陪伴', '节日期间友好问候与陪伴聊天,传递祝福。', '12.90'),
|
||||||
|
('睡前放松畅聊', '睡前轻松聊天,帮助放松,友好结束一天。', '15.90'),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
BANNED_WORDS = (
|
||||||
|
'代练', '代打', '上分', '上段', '包星', '定位赛', '代肝',
|
||||||
|
'代刷', '代打手', '代练平台', '代练服务', '护航上分',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '龙先生审核类型改为娱乐陪伴并向 lxs 写入正能量专区/商品(默认 dry-run)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--apply', action='store_true', help='真正写库')
|
||||||
|
parser.add_argument(
|
||||||
|
'--i-confirm',
|
||||||
|
type=str,
|
||||||
|
default='',
|
||||||
|
help=f'必须等于 {CONFIRM_TOKEN} 才允许 --apply',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--list',
|
||||||
|
action='store_true',
|
||||||
|
help='只列出当前审核类型及 lxs 存量',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--skip-rename-types',
|
||||||
|
action='store_true',
|
||||||
|
help='不修改全局审核类型名称,仅重建 lxs 专区/商品',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from jituan.models import Club
|
||||||
|
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||||
|
|
||||||
|
apply = bool(options.get('apply'))
|
||||||
|
confirm = (options.get('i_confirm') or '').strip()
|
||||||
|
skip_rename = bool(options.get('skip_rename_types'))
|
||||||
|
|
||||||
|
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||||
|
if not club:
|
||||||
|
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
|
||||||
|
if NAME_HINT not in (club.name or ''):
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:{ALLOWED_CLUB_ID} name={club.name!r} 不含「{NAME_HINT}」'
|
||||||
|
)
|
||||||
|
|
||||||
|
audit_types = list(
|
||||||
|
ShangpinLeixing.query.filter(shenhezhuangtai=SHENHE).order_by('id')
|
||||||
|
)
|
||||||
|
if len(audit_types) != EXPECTED_TYPE_COUNT:
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:审核类型数量应为 {EXPECTED_TYPE_COUNT},'
|
||||||
|
f'当前为 {len(audit_types)}:'
|
||||||
|
+ ', '.join(f'id={t.id}「{t.jieshao}」' for t in audit_types)
|
||||||
|
)
|
||||||
|
|
||||||
|
leixing_ids = [t.id for t in audit_types]
|
||||||
|
|
||||||
|
def counts():
|
||||||
|
z = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
p = Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
return z, p
|
||||||
|
|
||||||
|
if options.get('list'):
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
f'审核类型 → 计划改名;lxs 当前专区/商品:'
|
||||||
|
))
|
||||||
|
for i, t in enumerate(audit_types):
|
||||||
|
z = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID, leixing_id=t.id,
|
||||||
|
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
p = Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID, leixing_id=t.id,
|
||||||
|
shenhezhuangtai=SHENHE, dianpu__isnull=True,
|
||||||
|
).count()
|
||||||
|
self.stdout.write(
|
||||||
|
f' id={t.id}「{t.jieshao}」→「{TYPE_NAMES[i]}」 '
|
||||||
|
f'lxs zones={z} products={p}'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 文案自检
|
||||||
|
for zone_list in PRODUCTS_PER_ZONE:
|
||||||
|
if len(zone_list) != 10:
|
||||||
|
raise CommandError('内部错误:每个专区必须恰好 10 个商品')
|
||||||
|
for title, intro, _price in zone_list:
|
||||||
|
blob = title + intro
|
||||||
|
for w in BANNED_WORDS:
|
||||||
|
if w in blob:
|
||||||
|
raise CommandError(f'文案含禁止词「{w}」: {title}')
|
||||||
|
|
||||||
|
for zones in ZONE_NAMES_BY_TYPE:
|
||||||
|
if len(zones) != 2:
|
||||||
|
raise CommandError('内部错误:每个类型必须恰好 2 个专区')
|
||||||
|
|
||||||
|
if apply and confirm != CONFIRM_TOKEN:
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:--apply 必须带 --i-confirm={CONFIRM_TOKEN}\n'
|
||||||
|
f' python manage.py reset_lxs_shenhe_yule_peiban '
|
||||||
|
f'--apply --i-confirm={CONFIRM_TOKEN}'
|
||||||
|
)
|
||||||
|
|
||||||
|
old_z, old_p = counts()
|
||||||
|
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
f'\n===== {mode} 龙先生审核 → 娱乐陪伴 =====\n'
|
||||||
|
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
|
||||||
|
f'将清空 lxs 审核专区={old_z} 商品={old_p},再写入 '
|
||||||
|
f'{EXPECTED_TYPE_COUNT}类型×2专区×10商品=60\n'
|
||||||
|
f'图标: {DEFAULT_AVATAR}\n'
|
||||||
|
f'改名全局类型: {"否" if skip_rename else "是(影响所有俱乐部审核展示名)"}\n'
|
||||||
|
))
|
||||||
|
|
||||||
|
self.stdout.write('—— 类型改名计划 ——')
|
||||||
|
for i, t in enumerate(audit_types):
|
||||||
|
self.stdout.write(f' id={t.id}「{t.jieshao}」→「{TYPE_NAMES[i]}」')
|
||||||
|
|
||||||
|
self.stdout.write('—— 专区/商品结构 ——')
|
||||||
|
for i, tname in enumerate(TYPE_NAMES):
|
||||||
|
for zn in ZONE_NAMES_BY_TYPE[i]:
|
||||||
|
self.stdout.write(f' 「{tname}」/「{zn}」→ 10 个正能量商品')
|
||||||
|
|
||||||
|
self.stdout.write('—— 商品样例 ——')
|
||||||
|
for title, intro, price in PRODUCTS_PER_ZONE[0][:3]:
|
||||||
|
self.stdout.write(f' {title} ¥{price} | {intro}')
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'\n以上仅计划。确认后:\n'
|
||||||
|
f' python manage.py reset_lxs_shenhe_yule_peiban '
|
||||||
|
f'--apply --i-confirm={CONFIRM_TOKEN}\n'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'types_renamed': 0,
|
||||||
|
'deleted_products': 0,
|
||||||
|
'deleted_zones': 0,
|
||||||
|
'zones_created': 0,
|
||||||
|
'products_created': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
if not skip_rename:
|
||||||
|
for i, t in enumerate(audit_types):
|
||||||
|
new_name = TYPE_NAMES[i]
|
||||||
|
if (t.jieshao or '') != new_name:
|
||||||
|
t.jieshao = new_name
|
||||||
|
t.tupian_url = t.tupian_url or DEFAULT_AVATAR
|
||||||
|
t.save()
|
||||||
|
stats['types_renamed'] += 1
|
||||||
|
|
||||||
|
n_prod, _ = Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).delete()
|
||||||
|
n_zone, _ = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
leixing_id__in=leixing_ids,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).delete()
|
||||||
|
stats['deleted_products'] = n_prod
|
||||||
|
stats['deleted_zones'] = n_zone
|
||||||
|
|
||||||
|
# 按类型顺序重建
|
||||||
|
for i, lx in enumerate(audit_types):
|
||||||
|
# 刷新名称(若已 rename)
|
||||||
|
lx = ShangpinLeixing.query.filter(id=lx.id).first()
|
||||||
|
zone_names = ZONE_NAMES_BY_TYPE[i]
|
||||||
|
# 两个专区共用同一组 10 商品模板?用户要每个专区 10 个 —
|
||||||
|
# 使用 PRODUCTS_PER_ZONE[i] 作为该类型两专区的商品池,
|
||||||
|
# 第一专区用全部 10 个;第二专区标题加专区后缀避免同类型下标题撞车,
|
||||||
|
# 或两专区用同一文案但标题带专区名区分。
|
||||||
|
base_products = PRODUCTS_PER_ZONE[i]
|
||||||
|
for zi, zname in enumerate(zone_names):
|
||||||
|
zrow = ShangpinZhuanqu.query.create(
|
||||||
|
mingzi=zname,
|
||||||
|
leixing_id=lx.id,
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
tupian_url=DEFAULT_AVATAR,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
paixu=20 - zi * 10,
|
||||||
|
shangjia_zhuangtai=True,
|
||||||
|
fengjin_zhuangtai=True,
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
stats['zones_created'] += 1
|
||||||
|
|
||||||
|
for pi, (title, intro, price) in enumerate(base_products):
|
||||||
|
# 同类型不同专区:标题加专区名,避免去重冲突且语义清晰
|
||||||
|
full_title = f'{title}·{zname}'
|
||||||
|
for w in BANNED_WORDS:
|
||||||
|
if w in full_title or w in intro:
|
||||||
|
raise CommandError(f'禁止词命中: {full_title}')
|
||||||
|
Shangpin.query.create(
|
||||||
|
biaoqian=full_title[:45],
|
||||||
|
jiage=Decimal(price),
|
||||||
|
kucun=99999,
|
||||||
|
leixing_id=lx.id,
|
||||||
|
zhuanqu_id=zrow.id,
|
||||||
|
zhenshi_xiaoliang=0,
|
||||||
|
duiwai_xiaoliang=0,
|
||||||
|
jieshao=intro,
|
||||||
|
xiadan_xuzhi=(
|
||||||
|
'本服务为正向休闲陪伴与交流,请文明沟通。'
|
||||||
|
'禁止任何代练、代打或不正当行为。'
|
||||||
|
),
|
||||||
|
guize_tupian='',
|
||||||
|
tupian_url=DEFAULT_AVATAR,
|
||||||
|
yaoqiuleixing=lx.yaoqiuleixing,
|
||||||
|
huiyuan_id=lx.huiyuan_id,
|
||||||
|
yongjin=lx.yongjin,
|
||||||
|
kaioi_ewai_dashou_fencheng=False,
|
||||||
|
ewai_dashou_fencheng=None,
|
||||||
|
paixu=100 - pi,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
shangjia_zhuangtai=True,
|
||||||
|
fengjin_zhuangtai=True,
|
||||||
|
shenhe_zhuangtai=True,
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
shi_zhuanpan=False,
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
stats['products_created'] += 1
|
||||||
|
|
||||||
|
if stats['zones_created'] != 6 or stats['products_created'] != 60:
|
||||||
|
raise CommandError(
|
||||||
|
f'数量异常: zones={stats["zones_created"]} products={stats["products_created"]},'
|
||||||
|
f'期望 6/60,已回滚'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'\n完成: {stats}\n'
|
||||||
|
f'均为审核专用(shenhezhuangtai=2),仅审核模式可见;文案已避开代练代打。'
|
||||||
|
))
|
||||||
80
jituan/management/commands/seed_club_lsx.py
Normal file
80
jituan/management/commands/seed_club_lsx.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""一键初始化龙先生电竞(lsx)俱乐部(默认不批量给所有客服加任职)。"""
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from jituan.models import Club
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '创建龙先生电竞俱乐部(lsx);任职请单独 seed_admin_assignments --phone ...'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--wx-appid', default='wx7ff90e9d024fcdb8', help='龙先生电竞小程序 appid',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--assignments',
|
||||||
|
action='store_true',
|
||||||
|
help='同时给 --phone 指定客服加 lsx 任职(需配合 --phone)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--phone', type=str, default='', help='与 --assignments 联用:客服手机号',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
wx_appid = (options.get('wx_appid') or '').strip()
|
||||||
|
club_id = 'lsx'
|
||||||
|
name = '龙先生电竞'
|
||||||
|
|
||||||
|
# 俱乐部应由后台「俱乐部配置」创建并上传证书;本命令不再自动 create_club
|
||||||
|
club = Club.query.filter(club_id=club_id).first()
|
||||||
|
if club is None:
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
f'俱乐部 {club_id} 不存在。请先在后台创建并上传支付 key,'
|
||||||
|
f'不要用脚本自动建俱乐部(避免生成无证书空配置)。'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
if wx_appid and (club.wx_appid or '') != wx_appid:
|
||||||
|
club.wx_appid = wx_appid
|
||||||
|
club.save(update_fields=['wx_appid'])
|
||||||
|
self.stdout.write(f'已更新 {club_id}.wx_appid = {wx_appid}')
|
||||||
|
else:
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'俱乐部 {club_id} 已存在(name={club.name}),跳过创建。'
|
||||||
|
))
|
||||||
|
|
||||||
|
if options.get('assignments'):
|
||||||
|
phone = (options.get('phone') or '').strip()
|
||||||
|
if not phone:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'未指定 --phone,跳过任职。示例:'
|
||||||
|
'python manage.py seed_club_lsx --assignments --phone 你的手机号'
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
call_command(
|
||||||
|
'seed_admin_assignments',
|
||||||
|
club_id=club_id,
|
||||||
|
phone=phone,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.stdout.write(
|
||||||
|
'未加任职。需要时在后台「数据范围」配置,或:'
|
||||||
|
'python manage.py seed_admin_assignments --phone 手机号 --club-id lsx'
|
||||||
|
)
|
||||||
|
|
||||||
|
keyed = any([
|
||||||
|
(club.pay_key_path or '').strip(),
|
||||||
|
(club.pay_cert_path or '').strip(),
|
||||||
|
(club.private_key_path or '').strip(),
|
||||||
|
(club.platform_cert_dir or '').strip(),
|
||||||
|
])
|
||||||
|
if not keyed:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
f'{club_id} 当前无证书路径,请在后台上传 key;'
|
||||||
|
f'若存在重复空俱乐部可用:python manage.py cleanup_lsx_empty_club'
|
||||||
|
))
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'龙先生电竞 {club_id} 检查完成。'
|
||||||
|
))
|
||||||
153
jituan/management/commands/seed_club_withdraw_audit_perm.py
Normal file
153
jituan/management/commands/seed_club_withdraw_audit_perm.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""确保提现审核权限码 005bb,并按俱乐部挂到指定角色(可重复执行)。
|
||||||
|
|
||||||
|
权限码全局共用;隔离靠 Role.ClubID + 请求头俱乐部。
|
||||||
|
集团高管仍可跨俱乐部;俱乐部管理员仅本俱乐部。
|
||||||
|
|
||||||
|
用法示例:
|
||||||
|
python manage.py seed_club_withdraw_audit_perm
|
||||||
|
python manage.py seed_club_withdraw_audit_perm --club-id lxs --role-name 管理员
|
||||||
|
python manage.py seed_club_withdraw_audit_perm --all-clubs --role-name 管理员
|
||||||
|
python manage.py seed_club_withdraw_audit_perm --all-clubs --role-name 管理员 --dry-run
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from gvsdsdk.models import Permission, Role, RolePermission
|
||||||
|
|
||||||
|
from jituan.models import Club
|
||||||
|
|
||||||
|
WITHDRAW_AUDIT_PERM = '005bb'
|
||||||
|
WITHDRAW_AUDIT_NAME = '提现审核(按俱乐部)'
|
||||||
|
WITHDRAW_AUDIT_DESC = (
|
||||||
|
'审核本俱乐部提现申请(同意/拒绝/自动打款待收款)。'
|
||||||
|
'跨俱乐部仅集团高管可操作;须配合后台角色 ClubID 与数据范围任职。'
|
||||||
|
)
|
||||||
|
WITHDRAW_AUDIT_CATEGORY = '提现管理'
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '写入 005bb 提现审核权限码,并按俱乐部挂到角色'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--club-id', default='', help='仅处理该俱乐部角色(如 lxs / xq)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--all-clubs', action='store_true', help='处理全部启用中的俱乐部',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--role-name', default='',
|
||||||
|
help='角色名(如「管理员」);为空则只确保权限码存在,不挂角色',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run', action='store_true', help='只打印不写入',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options['dry_run']
|
||||||
|
club_id = (options.get('club_id') or '').strip()
|
||||||
|
all_clubs = options.get('all_clubs')
|
||||||
|
role_name = (options.get('role_name') or '').strip()
|
||||||
|
|
||||||
|
sample = Permission.objects.filter(PermStatus=1).first()
|
||||||
|
tenant_uuid = sample.TenantUUID if sample else uuid.UUID(
|
||||||
|
'00000000-0000-0000-0000-000000000001'
|
||||||
|
).bytes
|
||||||
|
|
||||||
|
perm = Permission.objects.filter(PermCode=WITHDRAW_AUDIT_PERM).first()
|
||||||
|
if perm:
|
||||||
|
self.stdout.write(f' 权限码已存在: {WITHDRAW_AUDIT_PERM}')
|
||||||
|
if not dry_run and (perm.PermName != WITHDRAW_AUDIT_NAME or not perm.PermDesc):
|
||||||
|
perm.PermName = WITHDRAW_AUDIT_NAME
|
||||||
|
perm.PermDesc = WITHDRAW_AUDIT_DESC
|
||||||
|
perm.PermCategory = WITHDRAW_AUDIT_CATEGORY
|
||||||
|
perm.save(update_fields=['PermName', 'PermDesc', 'PermCategory'])
|
||||||
|
self.stdout.write(self.style.SUCCESS(' 已更新 005bb 名称/说明'))
|
||||||
|
else:
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(f'would create perm {WITHDRAW_AUDIT_PERM}')
|
||||||
|
else:
|
||||||
|
perm = Permission.objects.create(
|
||||||
|
PermUUID=uuid.uuid4().bytes,
|
||||||
|
TenantUUID=tenant_uuid,
|
||||||
|
PermCode=WITHDRAW_AUDIT_PERM,
|
||||||
|
PermName=WITHDRAW_AUDIT_NAME,
|
||||||
|
PermDesc=WITHDRAW_AUDIT_DESC,
|
||||||
|
PermCategory=WITHDRAW_AUDIT_CATEGORY,
|
||||||
|
PermStatus=1,
|
||||||
|
CreateTime=timezone.now(),
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f' 新增权限: {WITHDRAW_AUDIT_PERM}'))
|
||||||
|
|
||||||
|
if not role_name:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(
|
||||||
|
'未指定 --role-name:仅确保权限码。'
|
||||||
|
'给俱乐部挂权请加:--club-id lxs --role-name 管理员'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not club_id and not all_clubs:
|
||||||
|
raise CommandError('挂角色时请指定 --club-id 或 --all-clubs')
|
||||||
|
|
||||||
|
if all_clubs:
|
||||||
|
club_ids = list(
|
||||||
|
Club.query.filter(status=1).values_list('club_id', flat=True)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if not Club.query.filter(club_id=club_id, status=1).exists():
|
||||||
|
raise CommandError(f'俱乐部不存在或未启用: {club_id}')
|
||||||
|
club_ids = [club_id]
|
||||||
|
|
||||||
|
if not perm and dry_run:
|
||||||
|
self.stdout.write('dry-run: skip role binding (perm not loaded)')
|
||||||
|
return
|
||||||
|
|
||||||
|
linked = 0
|
||||||
|
missing_roles = 0
|
||||||
|
with transaction.atomic():
|
||||||
|
for cid in club_ids:
|
||||||
|
role = Role.objects.filter(
|
||||||
|
RoleName=role_name, ClubID=cid, RoleStatus=1,
|
||||||
|
).first()
|
||||||
|
if not role:
|
||||||
|
missing_roles += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.WARNING(f' [{cid}] 无角色「{role_name}」,跳过')
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
exists = RolePermission.objects.filter(
|
||||||
|
RoleUUID=role.RoleUUID, PermUUID=perm.PermUUID,
|
||||||
|
).exists()
|
||||||
|
if exists:
|
||||||
|
self.stdout.write(f' [{cid}] 角色「{role_name}」已有 {WITHDRAW_AUDIT_PERM}')
|
||||||
|
continue
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(
|
||||||
|
f'would link {WITHDRAW_AUDIT_PERM} -> [{cid}] {role_name}'
|
||||||
|
)
|
||||||
|
linked += 1
|
||||||
|
continue
|
||||||
|
RolePermission.objects.create(
|
||||||
|
RolePermUUID=uuid.uuid4().bytes,
|
||||||
|
RoleUUID=role.RoleUUID,
|
||||||
|
PermUUID=perm.PermUUID,
|
||||||
|
CreateTime=timezone.now(),
|
||||||
|
)
|
||||||
|
linked += 1
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f' [{cid}] 已给角色「{role_name}」挂上 {WITHDRAW_AUDIT_PERM}'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(
|
||||||
|
f'完成:挂权 {linked},缺角色 {missing_roles}。'
|
||||||
|
f'另请确认该账号在「数据范围配置」有对应俱乐部 SINGLE_CLUB 任职。'
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -7,6 +7,7 @@ from django.core.management.base import BaseCommand
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
|
|
||||||
from jituan.constants import CLUB_ID_DEFAULT
|
from jituan.constants import CLUB_ID_DEFAULT
|
||||||
|
from jituan.services.wechat_pay import XQ_WX_PAY, WX_V3_CERT_SERIAL_NO_OVERRIDE
|
||||||
from jituan.models import (
|
from jituan.models import (
|
||||||
Club,
|
Club,
|
||||||
ClubHuiyuanPrice,
|
ClubHuiyuanPrice,
|
||||||
@@ -41,10 +42,11 @@ class Command(BaseCommand):
|
|||||||
'status': 1,
|
'status': 1,
|
||||||
'wx_appid': getattr(settings, 'WEIXIN_APPID', ''),
|
'wx_appid': getattr(settings, 'WEIXIN_APPID', ''),
|
||||||
'wx_secret': getattr(settings, 'WEIXIN_SECRET', ''),
|
'wx_secret': getattr(settings, 'WEIXIN_SECRET', ''),
|
||||||
'mch_id': getattr(settings, 'WEIXIN_MCHID', '') or wx_v3.get('MCHID', ''),
|
'mch_id': XQ_WX_PAY['mch_id'],
|
||||||
'pay_app_id': wx_v3.get('APPID', '') or getattr(settings, 'WEIXIN_APPID', ''),
|
'pay_app_id': XQ_WX_PAY['wx_appid'],
|
||||||
'api_v3_key': wx_v3.get('API_V3_KEY', ''),
|
'mch_key': XQ_WX_PAY['mch_key'],
|
||||||
'cert_serial_no': wx_v3.get('CERT_SERIAL_NO', ''),
|
'api_v3_key': XQ_WX_PAY['api_v3_key'],
|
||||||
|
'cert_serial_no': WX_V3_CERT_SERIAL_NO_OVERRIDE,
|
||||||
'private_key_path': wx_v3.get('PRIVATE_KEY_PATH', ''),
|
'private_key_path': wx_v3.get('PRIVATE_KEY_PATH', ''),
|
||||||
'platform_cert_dir': wx_v3.get('PLATFORM_CERT_DIR', ''),
|
'platform_cert_dir': wx_v3.get('PLATFORM_CERT_DIR', ''),
|
||||||
'config_json': {
|
'config_json': {
|
||||||
@@ -75,7 +77,7 @@ class Command(BaseCommand):
|
|||||||
if v and not getattr(club, k, None):
|
if v and not getattr(club, k, None):
|
||||||
setattr(club, k, v)
|
setattr(club, k, v)
|
||||||
# V3 字段允许用 settings 补全(证书路径等)
|
# V3 字段允许用 settings 补全(证书路径等)
|
||||||
for field in ('api_v3_key', 'cert_serial_no', 'private_key_path', 'platform_cert_dir', 'pay_app_id'):
|
for field in ('mch_key', 'api_v3_key', 'cert_serial_no', 'private_key_path', 'platform_cert_dir', 'pay_app_id'):
|
||||||
val = defaults.get(field)
|
val = defaults.get(field)
|
||||||
if val and not getattr(club, field, None):
|
if val and not getattr(club, field, None):
|
||||||
setattr(club, field, val)
|
setattr(club, field, val)
|
||||||
|
|||||||
@@ -26,12 +26,30 @@ EXTRA_PERMISSIONS = [
|
|||||||
'配置客服欢迎语、发图确认弹窗、关键词自动回复(会话管理-客服聊天配置)',
|
'配置客服欢迎语、发图确认弹窗、关键词自动回复(会话管理-客服聊天配置)',
|
||||||
'会话管理',
|
'会话管理',
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
'gdck666',
|
||||||
|
'投诉工单查看',
|
||||||
|
'查看用户投诉工单列表与详情',
|
||||||
|
'投诉工单',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'gdcl666',
|
||||||
|
'投诉工单处理',
|
||||||
|
'处理/关闭用户投诉工单',
|
||||||
|
'投诉工单',
|
||||||
|
),
|
||||||
(
|
(
|
||||||
'sfbq666',
|
'sfbq666',
|
||||||
'身份装饰标签管理',
|
'身份装饰标签管理',
|
||||||
'管理小程序身份装饰标签(与用户管理-身份装饰标签菜单对应)',
|
'管理小程序身份装饰标签(与用户管理-身份装饰标签菜单对应)',
|
||||||
'用户管理',
|
'用户管理',
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
'005bb',
|
||||||
|
'提现审核(按俱乐部)',
|
||||||
|
'审核本俱乐部提现(同意/拒绝/自动打款)。跨俱乐部仅集团高管;须角色 ClubID + 数据范围任职。',
|
||||||
|
'提现管理',
|
||||||
|
),
|
||||||
# ---------- 店铺管理(999 系列:列表/详情/复制/提现等) ----------
|
# ---------- 店铺管理(999 系列:列表/详情/复制/提现等) ----------
|
||||||
('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'),
|
('999a', '店铺状态管理', '修改店铺封禁/正常状态', '店铺管理'),
|
||||||
('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'),
|
('999b', '店铺余额与创建', '修改可提现余额、添加新店铺', '店铺管理'),
|
||||||
|
|||||||
218
jituan/management/commands/seed_lsx_invite_data.py
Normal file
218
jituan/management/commands/seed_lsx_invite_data.py
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
"""龙先生电竞邀请链种子:插入组长+管事(含邀请码)。
|
||||||
|
|
||||||
|
默认 club_id=lsx;若找不到会列出库里全部俱乐部,便于核对真实 club_id。
|
||||||
|
可用:
|
||||||
|
python manage.py seed_lsx_invite_data
|
||||||
|
python manage.py seed_lsx_invite_data --club-id 你的真实id
|
||||||
|
python manage.py seed_lsx_invite_data --list-clubs
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from jituan.models import Club, ClubHuiyuanPrice
|
||||||
|
from users.business_models import User
|
||||||
|
from users.models import UserBoss, UserGuanshi, UserZuzhang
|
||||||
|
from utils.invitationcode_utils import CreateInvitationCode
|
||||||
|
|
||||||
|
DEFAULT_CLUB_ID = 'lsx'
|
||||||
|
WX_APPID = 'wx7ff90e9d024fcdb8'
|
||||||
|
|
||||||
|
# 固定种子账号(可重复执行,幂等;UID/手机避开 xzj 的 9900001/2)
|
||||||
|
SEED_ZUZHANG = {
|
||||||
|
'UserUID': '9910001',
|
||||||
|
'OpenID': 'lsx_seed_openid_zuzhang_v1',
|
||||||
|
'Phone': '19910000001',
|
||||||
|
'UserName': 'lsx_seed_zuzhang',
|
||||||
|
'nickname': '龙先生种子组长',
|
||||||
|
}
|
||||||
|
SEED_GUANSHI = {
|
||||||
|
'UserUID': '9910002',
|
||||||
|
'OpenID': 'lsx_seed_openid_guanshi_v1',
|
||||||
|
'Phone': '19910000002',
|
||||||
|
'UserName': 'lsx_seed_guanshi',
|
||||||
|
'nickname': '龙先生种子管事',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _list_clubs(stdout, style):
|
||||||
|
clubs = list(Club.query.order_by('sort_order', 'club_id'))
|
||||||
|
stdout.write(style.SUCCESS('=' * 60))
|
||||||
|
stdout.write(f'库里俱乐部共 {len(clubs)} 个:')
|
||||||
|
for c in clubs:
|
||||||
|
stdout.write('-' * 40)
|
||||||
|
stdout.write(f' club_id={c.club_id!r} name={c.name!r} status={c.status}')
|
||||||
|
stdout.write(f' wx_appid={c.wx_appid or "(空)"}')
|
||||||
|
stdout.write(f' mch_id={c.mch_id or "(空)"} withdraw_mch_id={getattr(c, "withdraw_mch_id", "") or "(空)"}')
|
||||||
|
stdout.write(
|
||||||
|
f' pay_key_path={c.pay_key_path or "(空)"}'
|
||||||
|
)
|
||||||
|
stdout.write(style.SUCCESS('=' * 60))
|
||||||
|
return clubs
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_club(club_id: str, stdout, style):
|
||||||
|
"""优先精确 club_id;其次 wx_appid;再名称含「龙先生」。"""
|
||||||
|
cid = (club_id or DEFAULT_CLUB_ID).strip()
|
||||||
|
club = Club.query.filter(club_id=cid).first()
|
||||||
|
if club:
|
||||||
|
return club
|
||||||
|
|
||||||
|
stdout.write(style.WARNING(f'精确 club_id={cid!r} 不存在,尝试按 AppID/名称匹配…'))
|
||||||
|
|
||||||
|
by_app = Club.query.filter(wx_appid=WX_APPID).first()
|
||||||
|
if by_app:
|
||||||
|
stdout.write(style.WARNING(
|
||||||
|
f'按 wx_appid={WX_APPID} 命中: club_id={by_app.club_id!r} name={by_app.name!r}'
|
||||||
|
))
|
||||||
|
return by_app
|
||||||
|
|
||||||
|
by_name = Club.query.filter(name__icontains='龙先生').order_by('club_id')
|
||||||
|
names = list(by_name)
|
||||||
|
if len(names) == 1:
|
||||||
|
c = names[0]
|
||||||
|
stdout.write(style.WARNING(
|
||||||
|
f'按名称「龙先生」命中唯一: club_id={c.club_id!r} name={c.name!r}'
|
||||||
|
))
|
||||||
|
return c
|
||||||
|
if len(names) > 1:
|
||||||
|
stdout.write(style.ERROR('名称含「龙先生」的俱乐部有多条,请用 --club-id 指定:'))
|
||||||
|
for c in names:
|
||||||
|
stdout.write(f' - {c.club_id!r} / {c.name!r}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_user(meta, club_id):
|
||||||
|
user = User.query.filter(UserUID=meta['UserUID']).first()
|
||||||
|
if not user:
|
||||||
|
user = User.query.filter(OpenID=meta['OpenID']).first()
|
||||||
|
if user:
|
||||||
|
if (user.ClubID or '') != club_id:
|
||||||
|
user.ClubID = club_id
|
||||||
|
user.save(update_fields=['ClubID'])
|
||||||
|
return user, False
|
||||||
|
|
||||||
|
user = User.query.create(
|
||||||
|
UserUID=meta['UserUID'],
|
||||||
|
UserName=meta['UserName'],
|
||||||
|
OpenID=meta['OpenID'],
|
||||||
|
Phone=meta['Phone'],
|
||||||
|
ClubID=club_id,
|
||||||
|
)
|
||||||
|
UserBoss.query.create(user=user, nickname=meta['nickname'])
|
||||||
|
return user, True
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_zuzhang(user):
|
||||||
|
try:
|
||||||
|
return user.ZuzhangProfile, False
|
||||||
|
except UserZuzhang.DoesNotExist:
|
||||||
|
pass
|
||||||
|
yaoqingma = CreateInvitationCode(str(user.UserUID))
|
||||||
|
z = UserZuzhang.query.create(
|
||||||
|
user=user,
|
||||||
|
yaoqingma=yaoqingma,
|
||||||
|
zhuangtai=1,
|
||||||
|
)
|
||||||
|
return z, True
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_guanshi(user, zuzhang_uid):
|
||||||
|
try:
|
||||||
|
return user.GuanshiProfile, False
|
||||||
|
except UserGuanshi.DoesNotExist:
|
||||||
|
pass
|
||||||
|
yaoqingma = CreateInvitationCode(str(user.UserUID))
|
||||||
|
g = UserGuanshi.query.create(
|
||||||
|
user=user,
|
||||||
|
yaoqingma=yaoqingma,
|
||||||
|
yaoqingren=zuzhang_uid,
|
||||||
|
zhuangtai=1,
|
||||||
|
)
|
||||||
|
return g, True
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '插入龙先生电竞种子组长+管事(含邀请码);找不到俱乐部时会列出全部 club'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--club-id',
|
||||||
|
type=str,
|
||||||
|
default=DEFAULT_CLUB_ID,
|
||||||
|
help='目标俱乐部 ID(默认 lsx;若后台建的不是 lsx 请改这里)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--list-clubs',
|
||||||
|
action='store_true',
|
||||||
|
help='只列出库里所有俱乐部,不写入种子',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--force-new-codes',
|
||||||
|
action='store_true',
|
||||||
|
help='已存在时重新生成邀请码(慎用)',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if options.get('list_clubs'):
|
||||||
|
_list_clubs(self.stdout, self.style)
|
||||||
|
return
|
||||||
|
|
||||||
|
want_id = (options.get('club_id') or DEFAULT_CLUB_ID).strip()
|
||||||
|
club = _resolve_club(want_id, self.stdout, self.style)
|
||||||
|
if club is None:
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
f'找不到可用俱乐部(请求 club_id={want_id!r})。'
|
||||||
|
))
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'可能原因:\n'
|
||||||
|
' 1) 后台创建时 club_id 不是 lsx(例如写成了别的拼音)\n'
|
||||||
|
' 2) 之前执行过 delete_club_by_mch_id --apply,把 lsx 主表删掉了\n'
|
||||||
|
'请先看下面列表,用真实 club_id 再跑:\n'
|
||||||
|
' python manage.py seed_lsx_invite_data --list-clubs\n'
|
||||||
|
' python manage.py seed_lsx_invite_data --club-id 真实id'
|
||||||
|
))
|
||||||
|
_list_clubs(self.stdout, self.style)
|
||||||
|
raise CommandError('俱乐部不存在,已中止(未写入任何种子)')
|
||||||
|
|
||||||
|
club_id = club.club_id
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'将写入种子到俱乐部: club_id={club_id!r} name={club.name!r} wx_appid={club.wx_appid or "(空)"}'
|
||||||
|
))
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
zz_user, zz_new = _ensure_user(SEED_ZUZHANG, club_id)
|
||||||
|
zz_profile, zz_profile_new = _ensure_zuzhang(zz_user)
|
||||||
|
if options.get('force_new_codes') and not zz_profile_new:
|
||||||
|
zz_profile.yaoqingma = CreateInvitationCode(str(zz_user.UserUID))
|
||||||
|
zz_profile.save(update_fields=['yaoqingma'])
|
||||||
|
|
||||||
|
gs_user, gs_new = _ensure_user(SEED_GUANSHI, club_id)
|
||||||
|
gs_profile, gs_profile_new = _ensure_guanshi(gs_user, zz_user.UserUID)
|
||||||
|
if options.get('force_new_codes') and not gs_profile_new:
|
||||||
|
gs_profile.yaoqingma = CreateInvitationCode(str(gs_user.UserUID))
|
||||||
|
gs_profile.save(update_fields=['yaoqingma'])
|
||||||
|
|
||||||
|
price_cnt = ClubHuiyuanPrice.query.filter(club_id=club_id).count()
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'龙先生电竞邀请链种子已就绪(club_id={club_id})'))
|
||||||
|
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||||
|
self.stdout.write(f'club_huiyuan_price 条数: {price_cnt}')
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write('【组长】管事注册用这个邀请码(guanshizhuce):')
|
||||||
|
self.stdout.write(f' UserUID={zz_user.UserUID} Phone={SEED_ZUZHANG["Phone"]}')
|
||||||
|
self.stdout.write(f' 组长邀请码 inviteCode = {zz_profile.yaoqingma}')
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write('【管事】打手/接单员注册用这个邀请码(dashouzhuce):')
|
||||||
|
self.stdout.write(f' UserUID={gs_user.UserUID} Phone={SEED_GUANSHI["Phone"]}')
|
||||||
|
self.stdout.write(f' 管事邀请码 inviteCode = {gs_profile.yaoqingma}')
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write('商家:后台「商家管理 → 添加商家」按用户UID开通')
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write('新建: 组长=%s 管事=%s' % (
|
||||||
|
'是' if zz_new or zz_profile_new else '否(已存在)',
|
||||||
|
'是' if gs_new or gs_profile_new else '否(已存在)',
|
||||||
|
))
|
||||||
|
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||||
311
jituan/management/commands/seed_lxs_shenhe_huhang_catalog.py
Normal file
311
jituan/management/commands/seed_lxs_shenhe_huhang_catalog.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
为龙先生电竞(lxs)写入审核专用(shenhezhuangtai=2)「游戏护航/陪伴」公共商品目录。
|
||||||
|
|
||||||
|
范围:
|
||||||
|
- 仅 club_id=lxs(名称须含「龙先生」)
|
||||||
|
- 全局审核类型:取 ShangpinLeixing 中 shenhezhuangtai=2 的第一条;若无则创建「娱乐护航」
|
||||||
|
- 创建若干审核专区;30 个商品主要挂在第一个专区
|
||||||
|
- 商品图统一用默认头像相对路径 a_long/morentouxiang.jpg
|
||||||
|
- 文案仅正向「护航/陪伴/开黑」,绝不出现代练、代打、上分等
|
||||||
|
|
||||||
|
不碰:正常商品(shenhezhuangtai=1)、其它俱乐部、店铺商品。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python manage.py seed_lxs_shenhe_huhang_catalog
|
||||||
|
python manage.py seed_lxs_shenhe_huhang_catalog --apply --i-confirm-club=lxs
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
ALLOWED_CLUB_ID = 'lxs'
|
||||||
|
NAME_HINT = '龙先生'
|
||||||
|
DEFAULT_AVATAR = 'a_long/morentouxiang.jpg'
|
||||||
|
SHENHE = 2 # 审核专用
|
||||||
|
|
||||||
|
# 专区(审核版)
|
||||||
|
ZONE_DEFS = [
|
||||||
|
{'mingzi': '休闲开黑', 'paixu': 30},
|
||||||
|
{'mingzi': '语音陪伴', 'paixu': 20},
|
||||||
|
{'mingzi': '团队协作', 'paixu': 10},
|
||||||
|
]
|
||||||
|
|
||||||
|
# 30 个正向护航/陪伴向商品(标题 + 介绍 + 价格),禁止代练代打语义
|
||||||
|
PRODUCT_DEFS = [
|
||||||
|
('王者荣耀·休闲开黑陪伴', '友好组队开黑,轻松娱乐,语音交流,快乐优先。', '19.90'),
|
||||||
|
('和平精英·小队同行', '休闲模式组队畅玩,互相配合,体验团队乐趣。', '19.90'),
|
||||||
|
('原神·探索同行', '一起探索地图与活动,聊天解谜,轻松愉快。', '29.90'),
|
||||||
|
('星穹铁道·剧情陪伴', '陪你体验剧情与活动关卡,节奏友好,交流顺畅。', '29.90'),
|
||||||
|
('无畏契约·娱乐五排', '娱乐局友好开黑,沟通协作,享受对局过程。', '25.90'),
|
||||||
|
('英雄联盟·娱乐双排', '休闲娱乐局陪伴,友好沟通,重在开心。', '25.90'),
|
||||||
|
('永劫无间·双人协作', '双人娱乐局同行,互相支援,体验动作乐趣。', '22.90'),
|
||||||
|
('三角洲行动·小队协作', '小队任务友好配合,沟通顺畅,快乐完成目标。', '29.90'),
|
||||||
|
('蛋仔派对·欢乐同乐', '休闲派对模式一起玩,轻松互动,气氛友好。', '15.90'),
|
||||||
|
('逆水寒·社交同游', '一起游历风景与活动,聊天社交,轻松惬意。', '29.90'),
|
||||||
|
('鸣潮·旅途陪伴', '结伴体验开放世界内容,节奏舒适,友好交流。', '29.90'),
|
||||||
|
('绝区零·日常同行', '日常内容一起体验,互相提醒,轻松完成。', '22.90'),
|
||||||
|
('崩坏3·活动陪伴', '活动关卡友好同行,沟通配合,享受剧情。', '19.90'),
|
||||||
|
('命运冠位指定·活动同行', '活动副本休闲体验,节奏友好,互相鼓励。', '19.90'),
|
||||||
|
('明日方舟·关卡陪伴', '关卡推图友好协助,讲解思路,轻松通关体验。', '22.90'),
|
||||||
|
('第五人格·娱乐开黑', '娱乐模式组队,友好沟通,享受对抗乐趣。', '19.90'),
|
||||||
|
('CS2·休闲约战', '休闲模式友好对局,语音交流,重在体验。', '25.90'),
|
||||||
|
('Dota2·娱乐组队', '娱乐局结伴开黑,沟通协作,快乐优先。', '25.90'),
|
||||||
|
('炉石传说·休闲对局', '休闲模式一起玩,交流构筑心得,气氛轻松。', '15.90'),
|
||||||
|
('金铲铲之战·娱乐对局', '娱乐模式畅玩,聊天互动,轻松愉快。', '15.90'),
|
||||||
|
('穿越火线·休闲开黑', '休闲模式组队,友好沟通,享受射击乐趣。', '19.90'),
|
||||||
|
('CFHD·娱乐同行', '娱乐局结伴,语音交流,过程友好。', '19.90'),
|
||||||
|
('NBA2K·友谊赛陪伴', '友谊赛模式一起打,轻松互动,享受比赛。', '22.90'),
|
||||||
|
('FIFA·友谊赛同行', '友谊赛友好对战,交流心得,气氛轻松。', '22.90'),
|
||||||
|
('我的世界·建造同行', '一起建造与探索,创意交流,氛围友好。', '19.90'),
|
||||||
|
('幻兽帕鲁·合作体验', '合作模式结伴体验,互相帮助,轻松有趣。', '19.90'),
|
||||||
|
('双人成行·合作通关', '合作解谜一起玩,沟通配合,欢乐通关。', '29.90'),
|
||||||
|
('It Takes Two·双人协作', '双人关卡友好协作,互动解谜,享受过程。', '29.90'),
|
||||||
|
('斯普拉遁·休闲对战', '休闲对战结伴,轻松交流,享受涂色乐趣。', '15.90'),
|
||||||
|
('动物森友会·岛上同游', '一起逛岛、装饰与活动,聊天放松,氛围温馨。', '19.90'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '龙先生(lxs)审核版护航商品种子(默认 dry-run)'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--apply', action='store_true', help='真正写库')
|
||||||
|
parser.add_argument(
|
||||||
|
'--i-confirm-club',
|
||||||
|
type=str,
|
||||||
|
default='',
|
||||||
|
help='必须等于 lxs 才允许 --apply',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--club-id',
|
||||||
|
type=str,
|
||||||
|
default=ALLOWED_CLUB_ID,
|
||||||
|
help='仅允许 lxs',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
from jituan.models import Club
|
||||||
|
from products.models import Shangpin, ShangpinLeixing, ShangpinZhuanqu
|
||||||
|
|
||||||
|
club_id = (options.get('club_id') or '').strip()
|
||||||
|
apply = bool(options.get('apply'))
|
||||||
|
confirm = (options.get('i_confirm_club') or '').strip()
|
||||||
|
|
||||||
|
if club_id != ALLOWED_CLUB_ID:
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:只允许 club_id={ALLOWED_CLUB_ID!r},你传的是 {club_id!r}'
|
||||||
|
)
|
||||||
|
|
||||||
|
club = Club.query.filter(club_id=ALLOWED_CLUB_ID).first()
|
||||||
|
if not club:
|
||||||
|
raise CommandError(f'俱乐部 {ALLOWED_CLUB_ID} 不存在')
|
||||||
|
if NAME_HINT not in (club.name or ''):
|
||||||
|
raise CommandError(
|
||||||
|
f'安全拦截:{ALLOWED_CLUB_ID} 的 name={club.name!r} 不含「{NAME_HINT}」'
|
||||||
|
)
|
||||||
|
|
||||||
|
if apply and confirm != ALLOWED_CLUB_ID:
|
||||||
|
raise CommandError(
|
||||||
|
'安全拦截:--apply 必须带 --i-confirm-club=lxs\n'
|
||||||
|
' python manage.py seed_lxs_shenhe_huhang_catalog --apply --i-confirm-club=lxs'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 审核类型:第一条;没有则计划创建
|
||||||
|
leixing = (
|
||||||
|
ShangpinLeixing.query
|
||||||
|
.filter(shenhezhuangtai=SHENHE)
|
||||||
|
.order_by('id')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
leixing_action = 'REUSE'
|
||||||
|
if not leixing:
|
||||||
|
leixing_action = 'CREATE'
|
||||||
|
leixing = None
|
||||||
|
|
||||||
|
# 专区计划
|
||||||
|
zone_plans = []
|
||||||
|
for zdef in ZONE_DEFS:
|
||||||
|
exist = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
mingzi=zdef['mingzi'],
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist and leixing and exist.leixing_id == leixing.id:
|
||||||
|
zone_plans.append({'def': zdef, 'action': 'SKIP', 'row': exist})
|
||||||
|
elif exist and not leixing:
|
||||||
|
# 类型尚未创建,名称撞车则复用该专区(apply 时再绑类型)
|
||||||
|
zone_plans.append({'def': zdef, 'action': 'SKIP', 'row': exist})
|
||||||
|
else:
|
||||||
|
zone_plans.append({'def': zdef, 'action': 'CREATE', 'row': None})
|
||||||
|
|
||||||
|
# 商品计划:全部挂第一个专区
|
||||||
|
first_zone_name = ZONE_DEFS[0]['mingzi']
|
||||||
|
product_plans = []
|
||||||
|
for title, intro, price in PRODUCT_DEFS:
|
||||||
|
# 预检:同 club + 同标题 + 审核态是否已有
|
||||||
|
exist_p = Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
biaoqian=title,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
product_plans.append({
|
||||||
|
'title': title,
|
||||||
|
'intro': intro,
|
||||||
|
'price': price,
|
||||||
|
'action': 'SKIP' if exist_p else 'CREATE',
|
||||||
|
'exist_id': exist_p.id if exist_p else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
mode = 'APPLY' if apply else 'DRY-RUN'
|
||||||
|
self.stdout.write(self.style.NOTICE(
|
||||||
|
f'\n===== {mode} 龙先生审核版护航商品 =====\n'
|
||||||
|
f'club={ALLOWED_CLUB_ID} ({club.name})\n'
|
||||||
|
f'审核类型: {leixing_action} '
|
||||||
|
f'{("id="+str(leixing.id)+" name="+repr(leixing.jieshao)) if leixing else "将创建「娱乐护航」"}\n'
|
||||||
|
f'默认图标: {DEFAULT_AVATAR}\n'
|
||||||
|
f'专区计划: create={sum(1 for z in zone_plans if z["action"]=="CREATE")} '
|
||||||
|
f'skip={sum(1 for z in zone_plans if z["action"]=="SKIP")}\n'
|
||||||
|
f'商品计划: create={sum(1 for p in product_plans if p["action"]=="CREATE")} '
|
||||||
|
f'skip={sum(1 for p in product_plans if p["action"]=="SKIP")} '
|
||||||
|
f'(共{len(product_plans)},挂专区「{first_zone_name}」)\n'
|
||||||
|
))
|
||||||
|
|
||||||
|
self.stdout.write('—— 专区 ——')
|
||||||
|
for z in zone_plans:
|
||||||
|
self.stdout.write(f' [{z["action"]}] {z["def"]["mingzi"]}')
|
||||||
|
|
||||||
|
self.stdout.write('—— 商品样例(前8)——')
|
||||||
|
for p in product_plans[:8]:
|
||||||
|
self.stdout.write(f' [{p["action"]}] {p["title"]} ¥{p["price"]}')
|
||||||
|
if len(product_plans) > 8:
|
||||||
|
self.stdout.write(f' ... 其余 {len(product_plans)-8} 个省略')
|
||||||
|
|
||||||
|
banned = ('代练', '代打', '上分', '上段', '包星', '定位赛代')
|
||||||
|
for p in product_plans:
|
||||||
|
blob = p['title'] + p['intro']
|
||||||
|
for b in banned:
|
||||||
|
if b in blob:
|
||||||
|
raise CommandError(f'文案含禁止词「{b}」: {p["title"]}')
|
||||||
|
|
||||||
|
if not apply:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'\n以上仅计划。确认后:\n'
|
||||||
|
' python manage.py seed_lxs_shenhe_huhang_catalog --apply --i-confirm-club=lxs\n'
|
||||||
|
))
|
||||||
|
return
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'leixing_created': 0,
|
||||||
|
'zones_created': 0,
|
||||||
|
'zones_skipped': 0,
|
||||||
|
'products_created': 0,
|
||||||
|
'products_skipped': 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
if leixing is None:
|
||||||
|
leixing = ShangpinLeixing.query.create(
|
||||||
|
jieshao='娱乐护航',
|
||||||
|
tupian_url=DEFAULT_AVATAR,
|
||||||
|
paixu=0,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
yaoqiuleixing=None,
|
||||||
|
huiyuan_id=None,
|
||||||
|
yongjin=None,
|
||||||
|
)
|
||||||
|
stats['leixing_created'] = 1
|
||||||
|
elif leixing.shenhezhuangtai != SHENHE:
|
||||||
|
raise CommandError('解析到的类型不是审核专用,已中止')
|
||||||
|
|
||||||
|
zone_id_by_name = {}
|
||||||
|
for zp in zone_plans:
|
||||||
|
zdef = zp['def']
|
||||||
|
if zp['action'] == 'SKIP' and zp['row']:
|
||||||
|
# 若旧专区 leixing 不对,不改(只增不改);仅复用 id
|
||||||
|
zone_id_by_name[zdef['mingzi']] = zp['row'].id
|
||||||
|
stats['zones_skipped'] += 1
|
||||||
|
continue
|
||||||
|
exist = ShangpinZhuanqu.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
mingzi=zdef['mingzi'],
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist:
|
||||||
|
zone_id_by_name[zdef['mingzi']] = exist.id
|
||||||
|
stats['zones_skipped'] += 1
|
||||||
|
continue
|
||||||
|
row = ShangpinZhuanqu.query.create(
|
||||||
|
mingzi=zdef['mingzi'],
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
tupian_url=DEFAULT_AVATAR,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
paixu=int(zdef['paixu']),
|
||||||
|
shangjia_zhuangtai=True,
|
||||||
|
fengjin_zhuangtai=True,
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
zone_id_by_name[zdef['mingzi']] = row.id
|
||||||
|
stats['zones_created'] += 1
|
||||||
|
|
||||||
|
first_zq_id = zone_id_by_name.get(first_zone_name)
|
||||||
|
if not first_zq_id:
|
||||||
|
raise CommandError(f'第一个专区「{first_zone_name}」未就绪')
|
||||||
|
|
||||||
|
for i, pp in enumerate(product_plans):
|
||||||
|
if pp['action'] == 'SKIP':
|
||||||
|
stats['products_skipped'] += 1
|
||||||
|
continue
|
||||||
|
exist_p = Shangpin.query.filter(
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
biaoqian=pp['title'],
|
||||||
|
zhuanqu_id=first_zq_id,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
dianpu__isnull=True,
|
||||||
|
).first()
|
||||||
|
if exist_p:
|
||||||
|
stats['products_skipped'] += 1
|
||||||
|
continue
|
||||||
|
Shangpin.query.create(
|
||||||
|
biaoqian=pp['title'],
|
||||||
|
jiage=Decimal(pp['price']),
|
||||||
|
kucun=99999,
|
||||||
|
leixing_id=leixing.id,
|
||||||
|
zhuanqu_id=first_zq_id,
|
||||||
|
zhenshi_xiaoliang=0,
|
||||||
|
duiwai_xiaoliang=0,
|
||||||
|
jieshao=pp['intro'],
|
||||||
|
xiadan_xuzhi='请保持友好沟通,本服务为休闲娱乐陪伴,禁止不良言行。',
|
||||||
|
guize_tupian='',
|
||||||
|
tupian_url=DEFAULT_AVATAR,
|
||||||
|
yaoqiuleixing=leixing.yaoqiuleixing,
|
||||||
|
huiyuan_id=leixing.huiyuan_id,
|
||||||
|
yongjin=leixing.yongjin,
|
||||||
|
kaioi_ewai_dashou_fencheng=False,
|
||||||
|
ewai_dashou_fencheng=None,
|
||||||
|
paixu=1000 - i,
|
||||||
|
shenhezhuangtai=SHENHE,
|
||||||
|
shangjia_zhuangtai=True,
|
||||||
|
fengjin_zhuangtai=True,
|
||||||
|
shenhe_zhuangtai=True,
|
||||||
|
club_id=ALLOWED_CLUB_ID,
|
||||||
|
shi_zhuanpan=False,
|
||||||
|
dianpu=None,
|
||||||
|
dianpu_leixing=None,
|
||||||
|
)
|
||||||
|
stats['products_created'] += 1
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'\n完成: {stats}\n'
|
||||||
|
f'审核类型 id={leixing.id}「{leixing.jieshao}」;'
|
||||||
|
f'商品均 shenhezhuangtai=2,仅审核模式可见。'
|
||||||
|
))
|
||||||
223
jituan/management/commands/seed_xx_invite_data.py
Normal file
223
jituan/management/commands/seed_xx_invite_data.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
"""小汐电竞邀请链种子:插入组长+管事(含邀请码)。
|
||||||
|
|
||||||
|
默认 club_id=xx;找不到会列出全部俱乐部。
|
||||||
|
可用:
|
||||||
|
python manage.py seed_xx_invite_data
|
||||||
|
python manage.py seed_xx_invite_data --club-id xx
|
||||||
|
python manage.py seed_xx_invite_data --list-clubs
|
||||||
|
"""
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from jituan.models import Club, ClubHuiyuanPrice
|
||||||
|
from users.business_models import User
|
||||||
|
from users.models import UserBoss, UserGuanshi, UserZuzhang
|
||||||
|
from utils.invitationcode_utils import CreateInvitationCode
|
||||||
|
|
||||||
|
DEFAULT_CLUB_ID = 'xx'
|
||||||
|
WX_APPID = 'wx0d445b76f93774fa'
|
||||||
|
NAME_HINTS = ('小汐', '小溪', '小西')
|
||||||
|
|
||||||
|
# 固定种子账号(可重复执行,幂等;UID/手机避开 xzj 990000x、lsx 991000x)
|
||||||
|
SEED_ZUZHANG = {
|
||||||
|
'UserUID': '9920001',
|
||||||
|
'OpenID': 'xx_seed_openid_zuzhang_v1',
|
||||||
|
'Phone': '19920000001',
|
||||||
|
'UserName': 'xx_seed_zuzhang',
|
||||||
|
'nickname': '小汐种子组长',
|
||||||
|
}
|
||||||
|
SEED_GUANSHI = {
|
||||||
|
'UserUID': '9920002',
|
||||||
|
'OpenID': 'xx_seed_openid_guanshi_v1',
|
||||||
|
'Phone': '19920000002',
|
||||||
|
'UserName': 'xx_seed_guanshi',
|
||||||
|
'nickname': '小汐种子管事',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _list_clubs(stdout, style):
|
||||||
|
clubs = list(Club.query.order_by('sort_order', 'club_id'))
|
||||||
|
stdout.write(style.SUCCESS('=' * 60))
|
||||||
|
stdout.write(f'库里俱乐部共 {len(clubs)} 个:')
|
||||||
|
for c in clubs:
|
||||||
|
stdout.write('-' * 40)
|
||||||
|
stdout.write(f' club_id={c.club_id!r} name={c.name!r} status={c.status}')
|
||||||
|
stdout.write(f' wx_appid={c.wx_appid or "(空)"}')
|
||||||
|
stdout.write(style.SUCCESS('=' * 60))
|
||||||
|
return clubs
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_club(club_id: str, stdout, style):
|
||||||
|
"""优先精确 club_id;其次 wx_appid;再名称含小汐/小溪/小西。"""
|
||||||
|
cid = (club_id or DEFAULT_CLUB_ID).strip()
|
||||||
|
club = Club.query.filter(club_id=cid).first()
|
||||||
|
if club:
|
||||||
|
return club
|
||||||
|
|
||||||
|
stdout.write(style.WARNING(f'精确 club_id={cid!r} 不存在,尝试按 AppID/名称匹配…'))
|
||||||
|
|
||||||
|
by_app = Club.query.filter(wx_appid=WX_APPID).first()
|
||||||
|
if by_app:
|
||||||
|
stdout.write(style.WARNING(
|
||||||
|
f'按 wx_appid={WX_APPID} 命中: club_id={by_app.club_id!r} name={by_app.name!r}'
|
||||||
|
))
|
||||||
|
return by_app
|
||||||
|
|
||||||
|
hits = []
|
||||||
|
for hint in NAME_HINTS:
|
||||||
|
hits.extend(list(Club.query.filter(name__icontains=hint)))
|
||||||
|
# 去重
|
||||||
|
seen = set()
|
||||||
|
unique = []
|
||||||
|
for c in hits:
|
||||||
|
if c.club_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(c.club_id)
|
||||||
|
unique.append(c)
|
||||||
|
|
||||||
|
if len(unique) == 1:
|
||||||
|
c = unique[0]
|
||||||
|
stdout.write(style.WARNING(
|
||||||
|
f'按名称命中唯一: club_id={c.club_id!r} name={c.name!r}'
|
||||||
|
))
|
||||||
|
return c
|
||||||
|
if len(unique) > 1:
|
||||||
|
stdout.write(style.ERROR('名称匹配到多条,请用 --club-id 指定:'))
|
||||||
|
for c in unique:
|
||||||
|
stdout.write(f' - {c.club_id!r} / {c.name!r}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_user(meta, club_id):
|
||||||
|
user = User.query.filter(UserUID=meta['UserUID']).first()
|
||||||
|
if not user:
|
||||||
|
user = User.query.filter(OpenID=meta['OpenID']).first()
|
||||||
|
if user:
|
||||||
|
if (user.ClubID or '') != club_id:
|
||||||
|
user.ClubID = club_id
|
||||||
|
user.save(update_fields=['ClubID'])
|
||||||
|
return user, False
|
||||||
|
|
||||||
|
user = User.query.create(
|
||||||
|
UserUID=meta['UserUID'],
|
||||||
|
UserName=meta['UserName'],
|
||||||
|
OpenID=meta['OpenID'],
|
||||||
|
Phone=meta['Phone'],
|
||||||
|
ClubID=club_id,
|
||||||
|
)
|
||||||
|
UserBoss.query.create(user=user, nickname=meta['nickname'])
|
||||||
|
return user, True
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_zuzhang(user):
|
||||||
|
try:
|
||||||
|
return user.ZuzhangProfile, False
|
||||||
|
except UserZuzhang.DoesNotExist:
|
||||||
|
pass
|
||||||
|
yaoqingma = CreateInvitationCode(str(user.UserUID))
|
||||||
|
z = UserZuzhang.query.create(
|
||||||
|
user=user,
|
||||||
|
yaoqingma=yaoqingma,
|
||||||
|
zhuangtai=1,
|
||||||
|
)
|
||||||
|
return z, True
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_guanshi(user, zuzhang_uid):
|
||||||
|
try:
|
||||||
|
return user.GuanshiProfile, False
|
||||||
|
except UserGuanshi.DoesNotExist:
|
||||||
|
pass
|
||||||
|
yaoqingma = CreateInvitationCode(str(user.UserUID))
|
||||||
|
g = UserGuanshi.query.create(
|
||||||
|
user=user,
|
||||||
|
yaoqingma=yaoqingma,
|
||||||
|
yaoqingren=zuzhang_uid,
|
||||||
|
zhuangtai=1,
|
||||||
|
)
|
||||||
|
return g, True
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '插入小汐电竞种子组长+管事(含邀请码);找不到俱乐部时会列出全部 club'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--club-id',
|
||||||
|
type=str,
|
||||||
|
default=DEFAULT_CLUB_ID,
|
||||||
|
help='目标俱乐部 ID(默认 xx)',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--list-clubs',
|
||||||
|
action='store_true',
|
||||||
|
help='只列出库里所有俱乐部,不写入种子',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--force-new-codes',
|
||||||
|
action='store_true',
|
||||||
|
help='已存在时重新生成邀请码(慎用)',
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if options.get('list_clubs'):
|
||||||
|
_list_clubs(self.stdout, self.style)
|
||||||
|
return
|
||||||
|
|
||||||
|
want_id = (options.get('club_id') or DEFAULT_CLUB_ID).strip()
|
||||||
|
club = _resolve_club(want_id, self.stdout, self.style)
|
||||||
|
if club is None:
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
f'找不到可用俱乐部(请求 club_id={want_id!r})。'
|
||||||
|
))
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
'请先在后台创建小汐俱乐部(club_id=xx),或:\n'
|
||||||
|
' python manage.py seed_xx_invite_data --list-clubs\n'
|
||||||
|
' python manage.py seed_xx_invite_data --club-id 真实id'
|
||||||
|
))
|
||||||
|
_list_clubs(self.stdout, self.style)
|
||||||
|
raise CommandError('俱乐部不存在,已中止(未写入任何种子)')
|
||||||
|
|
||||||
|
club_id = club.club_id
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'将写入种子到俱乐部: club_id={club_id!r} name={club.name!r} '
|
||||||
|
f'wx_appid={club.wx_appid or "(空)"}'
|
||||||
|
))
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
zz_user, zz_new = _ensure_user(SEED_ZUZHANG, club_id)
|
||||||
|
zz_profile, zz_profile_new = _ensure_zuzhang(zz_user)
|
||||||
|
if options.get('force_new_codes') and not zz_profile_new:
|
||||||
|
zz_profile.yaoqingma = CreateInvitationCode(str(zz_user.UserUID))
|
||||||
|
zz_profile.save(update_fields=['yaoqingma'])
|
||||||
|
|
||||||
|
gs_user, gs_new = _ensure_user(SEED_GUANSHI, club_id)
|
||||||
|
gs_profile, gs_profile_new = _ensure_guanshi(gs_user, zz_user.UserUID)
|
||||||
|
if options.get('force_new_codes') and not gs_profile_new:
|
||||||
|
gs_profile.yaoqingma = CreateInvitationCode(str(gs_user.UserUID))
|
||||||
|
gs_profile.save(update_fields=['yaoqingma'])
|
||||||
|
|
||||||
|
price_cnt = ClubHuiyuanPrice.query.filter(club_id=club_id).count()
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'小汐电竞邀请链种子已就绪(club_id={club_id})'))
|
||||||
|
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||||
|
self.stdout.write(f'club_huiyuan_price 条数: {price_cnt}')
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write('【组长】管事注册用这个邀请码(guanshizhuce):')
|
||||||
|
self.stdout.write(f' UserUID={zz_user.UserUID} Phone={SEED_ZUZHANG["Phone"]}')
|
||||||
|
self.stdout.write(f' 组长邀请码 inviteCode = {zz_profile.yaoqingma}')
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write('【管事】打手/接单员注册用这个邀请码(dashouzhuce):')
|
||||||
|
self.stdout.write(f' UserUID={gs_user.UserUID} Phone={SEED_GUANSHI["Phone"]}')
|
||||||
|
self.stdout.write(f' 管事邀请码 inviteCode = {gs_profile.yaoqingma}')
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write('商家:后台「商家管理 → 添加商家」按用户UID开通')
|
||||||
|
self.stdout.write('')
|
||||||
|
self.stdout.write('新建: 组长=%s 管事=%s' % (
|
||||||
|
'是' if zz_new or zz_profile_new else '否(已存在)',
|
||||||
|
'是' if gs_new or gs_profile_new else '否(已存在)',
|
||||||
|
))
|
||||||
|
self.stdout.write(self.style.SUCCESS('=' * 60))
|
||||||
114
jituan/management/commands/wx_v3_pay_diag.py
Normal file
114
jituan/management/commands/wx_v3_pay_diag.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""诊断微信 V3 商户证书/私钥/序列号是否匹配。用法: python manage.py wx_v3_pay_diag [--club xq]"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from jituan.constants import CLUB_ID_DEFAULT
|
||||||
|
from jituan.services.wechat_pay import (
|
||||||
|
XQ_WX_PAY,
|
||||||
|
get_wechat_v3_config,
|
||||||
|
resolve_merchant_cert_serial,
|
||||||
|
_load_pem_private_key,
|
||||||
|
_public_keys_match,
|
||||||
|
)
|
||||||
|
from utils.wechat_v3 import build_authorization
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = '诊断微信 V3 提现签名:私钥、证书序列号、商户号是否一致'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--club', default=CLUB_ID_DEFAULT, help='俱乐部 ID:xq 或 xzj')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
club_id = (options.get('club') or CLUB_ID_DEFAULT).strip()
|
||||||
|
cfg = get_wechat_v3_config(club_id)
|
||||||
|
|
||||||
|
self.stdout.write('=== 微信 V3 配置诊断 ===')
|
||||||
|
self.stdout.write(f"club_id : {cfg.get('club_id')}")
|
||||||
|
self.stdout.write(f"APPID : {cfg.get('APPID')}")
|
||||||
|
self.stdout.write(f"MCHID : {cfg.get('MCHID')} (期望 {XQ_WX_PAY['mch_id']})")
|
||||||
|
self.stdout.write(f"CERT_SERIAL : {cfg.get('CERT_SERIAL_NO')}")
|
||||||
|
self.stdout.write(f"API_V3_KEY : len={len(cfg.get('API_V3_KEY') or '')}")
|
||||||
|
key_path = cfg.get('PRIVATE_KEY_PATH') or ''
|
||||||
|
self.stdout.write(f"PRIVATE_KEY : {key_path}")
|
||||||
|
self.stdout.write(f"key_exists : {os.path.isfile(key_path)}")
|
||||||
|
|
||||||
|
cert_dir = os.path.dirname(os.path.abspath(key_path)) if key_path else ''
|
||||||
|
self.stdout.write(f"cert_dir : {cert_dir}")
|
||||||
|
if cert_dir and os.path.isdir(cert_dir):
|
||||||
|
self.stdout.write('cert_dir files:')
|
||||||
|
for name in sorted(os.listdir(cert_dir)):
|
||||||
|
if name.lower().endswith('.pem'):
|
||||||
|
self.stdout.write(f' - {name}')
|
||||||
|
|
||||||
|
serial_from_match = resolve_merchant_cert_serial(key_path, '')
|
||||||
|
self.stdout.write(f"matched_serial: {serial_from_match or '(未匹配到证书)'}")
|
||||||
|
|
||||||
|
if key_path and os.path.isfile(key_path):
|
||||||
|
try:
|
||||||
|
private_key = _load_pem_private_key(key_path)
|
||||||
|
self.stdout.write('private_key : 加载成功')
|
||||||
|
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
|
||||||
|
tested = []
|
||||||
|
for fname in sorted(os.listdir(cert_dir)):
|
||||||
|
if not fname.lower().endswith('.pem'):
|
||||||
|
continue
|
||||||
|
if fname.lower() in ('apiclient_key.pem',):
|
||||||
|
continue
|
||||||
|
cert_path = os.path.join(cert_dir, fname)
|
||||||
|
tested.append(fname)
|
||||||
|
try:
|
||||||
|
with open(cert_path, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
if b'PRIVATE KEY' in data:
|
||||||
|
self.stdout.write(f' {fname}: 是私钥,跳过')
|
||||||
|
continue
|
||||||
|
cert = x509.load_pem_x509_certificate(data, default_backend())
|
||||||
|
serial = format(cert.serial_number, 'X').upper()
|
||||||
|
matched = _public_keys_match(private_key, cert.public_key())
|
||||||
|
self.stdout.write(
|
||||||
|
f' {fname}: X509证书 serial={serial} 与私钥匹配={matched}'
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self.stdout.write(f' {fname}: 非X509商户证书 ({exc})')
|
||||||
|
|
||||||
|
if 'apiclient_cert.pem' not in tested:
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
'\n缺少 apiclient_cert.pem!'
|
||||||
|
'\n请到微信商户平台(1747846572) → 账户中心 → API安全 → 申请API证书'
|
||||||
|
'\n下载证书包,将 apiclient_cert.pem 放到目录:'
|
||||||
|
f'\n {cert_dir}'
|
||||||
|
'\n当前目录只有 apiclient_key.pem,没有商户证书,所以签名必然失败。'
|
||||||
|
'\npub_key.pem 是微信支付平台公钥,不能代替 apiclient_cert.pem。'
|
||||||
|
))
|
||||||
|
except Exception as exc:
|
||||||
|
self.stdout.write(self.style.ERROR(f'私钥/证书检查失败: {exc}'))
|
||||||
|
|
||||||
|
# 试探请求:查一个不存在的单号,仅验证签名是否通过(404=签名OK,401 SIGN_ERROR=签名失败)
|
||||||
|
url = 'https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/WX_V3_DIAG_TEST'
|
||||||
|
auth = build_authorization('GET', url, '', club_id=club_id)
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
'Authorization': auth,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'User-Agent': 'Mozilla/5.0',
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
self.stdout.write(f"\n探针 HTTP {resp.status_code}: {resp.text[:300]}")
|
||||||
|
if resp.status_code == 401:
|
||||||
|
self.stdout.write(self.style.ERROR(
|
||||||
|
'签名仍失败:请确认服务器 apiclient_key.pem / apiclient_cert.pem '
|
||||||
|
'是商户号 1747846572 在微信商户平台下载的那一对'
|
||||||
|
))
|
||||||
|
elif resp.status_code in (404, 200):
|
||||||
|
self.stdout.write(self.style.SUCCESS('签名已通过(404/200 表示微信接受了商户签名)'))
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
self.stdout.write(self.style.ERROR(f'探针请求失败: {exc}'))
|
||||||
68
jituan/migrations/0011_club_payment_channel.py
Normal file
68
jituan/migrations/0011_club_payment_channel.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('jituan', '0010_club_huiyuan_bundle_include'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ClubPaymentChannel',
|
||||||
|
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')),
|
||||||
|
('channel', models.CharField(
|
||||||
|
choices=[
|
||||||
|
('wechat_jsapi', '微信直连-小程序JSAPI'),
|
||||||
|
('wechat_v3', '微信直连-V3转账/退款'),
|
||||||
|
('alipay_wap', '支付宝-手机网站'),
|
||||||
|
('fubei_wx_mini', '付呗-微信小程序'),
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
max_length=32,
|
||||||
|
)),
|
||||||
|
('name', models.CharField(help_text='如:主付呗、备用微信', max_length=64, verbose_name='配置名称')),
|
||||||
|
('enabled', models.IntegerField(default=1, verbose_name='1启用0停用')),
|
||||||
|
('is_default', models.BooleanField(default=True, verbose_name='同通道默认配置')),
|
||||||
|
('sort_order', models.IntegerField(default=0, verbose_name='前端展示顺序')),
|
||||||
|
('sandbox', models.BooleanField(default=False, verbose_name='沙箱/联调')),
|
||||||
|
('app_id', models.CharField(blank=True, default='', max_length=64, verbose_name='AppID/付呗ID')),
|
||||||
|
('mch_id', models.CharField(blank=True, default='', max_length=64, verbose_name='商户号')),
|
||||||
|
('api_key_enc', models.TextField(blank=True, default='', verbose_name='API密钥/付呗Secret(加密)')),
|
||||||
|
('private_key_enc', models.TextField(blank=True, default='', verbose_name='应用私钥(加密)')),
|
||||||
|
('public_key_enc', models.TextField(blank=True, default='', verbose_name='平台公钥(加密或明文)')),
|
||||||
|
('cert_serial_no', models.CharField(blank=True, default='', max_length=64)),
|
||||||
|
('private_key_path', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('platform_cert_dir', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('notify_url', models.CharField(blank=True, default='', max_length=512, verbose_name='异步回调URL')),
|
||||||
|
('return_url', models.CharField(blank=True, default='', max_length=512, verbose_name='同步返回URL')),
|
||||||
|
('gateway_url', models.CharField(blank=True, default='', max_length=255, verbose_name='API网关')),
|
||||||
|
('mini_app_id', models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
help_text='付呗/微信JSAPI;空则回落 club.wx_appid',
|
||||||
|
max_length=32,
|
||||||
|
verbose_name='支付用小程序AppID',
|
||||||
|
)),
|
||||||
|
('extra_json', models.JSONField(blank=True, default=dict, verbose_name='扩展配置')),
|
||||||
|
('remark', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '俱乐部支付通道',
|
||||||
|
'db_table': 'club_payment_channel',
|
||||||
|
'unique_together': {('club_id', 'channel', 'name')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='clubpaymentchannel',
|
||||||
|
index=models.Index(fields=['club_id', 'channel', 'enabled'], name='idx_cpc_club_ch_en'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='clubpaymentchannel',
|
||||||
|
index=models.Index(fields=['club_id', 'is_default'], name='idx_cpc_club_default'),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
jituan/migrations/0012_club_mch_key.py
Normal file
33
jituan/migrations/0012_club_mch_key.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""club 表增加 mch_key 列(APIv2 小程序支付密钥),从 config_json 迁移历史数据。"""
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def copy_mch_key_from_config_json(apps, schema_editor):
|
||||||
|
Club = apps.get_model('jituan', 'Club')
|
||||||
|
for club in Club.objects.all():
|
||||||
|
cfg = club.config_json or {}
|
||||||
|
legacy = (cfg.get('mch_key') or cfg.get('shanghumiyao') or '').strip()
|
||||||
|
if legacy and not (club.mch_key or '').strip():
|
||||||
|
club.mch_key = legacy
|
||||||
|
club.save(update_fields=['mch_key'])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('jituan', '0011_club_payment_channel'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='mch_key',
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default='',
|
||||||
|
max_length=128,
|
||||||
|
verbose_name='APIv2商户密钥',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(copy_mch_key_from_config_json, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
70
jituan/migrations/0013_club_pay_withdraw_split.py
Normal file
70
jituan/migrations/0013_club_pay_withdraw_split.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('jituan', '0012_club_mch_key'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='pay_cert_path',
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, default='', help_text='apiclient_cert.pem,退款/V2 证书用',
|
||||||
|
max_length=512, verbose_name='收款商户证书路径',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='pay_key_path',
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, default='', help_text='apiclient_key.pem,退款/V2 证书用',
|
||||||
|
max_length=512, verbose_name='收款商户私钥路径',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='withdraw_mch_id',
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, default='', help_text='空则回落收款商户号 mch_id',
|
||||||
|
max_length=32, verbose_name='提现商户号',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='club',
|
||||||
|
name='mch_id',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=32, verbose_name='收款商户号'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='club',
|
||||||
|
name='pay_app_id',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=32, verbose_name='收款AppID'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='club',
|
||||||
|
name='mch_key',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=128, verbose_name='收款APIv2密钥'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='club',
|
||||||
|
name='api_v3_key',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=128, verbose_name='提现APIv3密钥'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='club',
|
||||||
|
name='cert_serial_no',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=64, verbose_name='提现证书序列号'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='club',
|
||||||
|
name='private_key_path',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=512, verbose_name='提现商户私钥路径'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='club',
|
||||||
|
name='platform_cert_dir',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=512, verbose_name='提现平台证书目录'),
|
||||||
|
),
|
||||||
|
]
|
||||||
41
jituan/migrations/0014_club_mini_pay_channel_and_ledger.py
Normal file
41
jituan/migrations/0014_club_mini_pay_channel_and_ledger.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('jituan', '0013_club_pay_withdraw_split'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='club',
|
||||||
|
name='mini_pay_channel',
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
default='wechat',
|
||||||
|
help_text='wechat=微信直连;fubei=付呗;默认 wechat,不影响现网',
|
||||||
|
max_length=16,
|
||||||
|
verbose_name='小程序收款通道',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='PayChannelLedger',
|
||||||
|
fields=[
|
||||||
|
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('out_trade_no', models.CharField(db_index=True, max_length=64, unique=True, verbose_name='商户订单号')),
|
||||||
|
('club_id', models.CharField(db_index=True, default='', max_length=16)),
|
||||||
|
('channel', models.CharField(db_index=True, max_length=32, verbose_name='通道')),
|
||||||
|
('channel_config_id', models.IntegerField(blank=True, null=True)),
|
||||||
|
('biz_type', models.CharField(blank=True, default='', max_length=16, verbose_name='order/czjilu')),
|
||||||
|
('provider_order_sn', models.CharField(blank=True, default='', max_length=64, verbose_name='通道侧订单号')),
|
||||||
|
('amount_yuan', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
|
||||||
|
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('UpdateTime', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '支付通道流水',
|
||||||
|
'db_table': 'pay_channel_ledger',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
31
jituan/migrations/0015_club_recharge_page_copy.py
Normal file
31
jituan/migrations/0015_club_recharge_page_copy.py
Normal file
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
35
jituan/migrations/0016_club_order_grab_link.py
Normal file
35
jituan/migrations/0016_club_order_grab_link.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Generated manually for ClubOrderGrabLink
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('jituan', '0015_club_recharge_page_copy'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ClubOrderGrabLink',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('grabber_club_id', models.CharField(db_index=True, max_length=16, verbose_name='抢单方俱乐部')),
|
||||||
|
('order_club_id', models.CharField(db_index=True, max_length=16, verbose_name='被抢订单所属俱乐部')),
|
||||||
|
('CreateTime', models.DateTimeField(auto_now_add=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '俱乐部订单互通抢单',
|
||||||
|
'db_table': 'club_order_grab_link',
|
||||||
|
'unique_together': {('grabber_club_id', 'order_club_id')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='clubordergrablink',
|
||||||
|
index=models.Index(fields=['grabber_club_id'], name='idx_cogl_grabber'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='clubordergrablink',
|
||||||
|
index=models.Index(fields=['order_club_id'], name='idx_cogl_order'),
|
||||||
|
),
|
||||||
|
]
|
||||||
144
jituan/models.py
144
jituan/models.py
@@ -13,12 +13,34 @@ class Club(QModel):
|
|||||||
status = models.IntegerField(default=1, verbose_name='1启用0停用')
|
status = models.IntegerField(default=1, verbose_name='1启用0停用')
|
||||||
wx_appid = models.CharField(max_length=32, blank=True, default='')
|
wx_appid = models.CharField(max_length=32, blank=True, default='')
|
||||||
wx_secret = models.CharField(max_length=128, blank=True, default='')
|
wx_secret = models.CharField(max_length=128, blank=True, default='')
|
||||||
mch_id = models.CharField(max_length=32, blank=True, default='')
|
# —— 点单/会员等收款商户(V2 JSAPI;退款也应对齐这套)——
|
||||||
pay_app_id = models.CharField(max_length=32, blank=True, default='')
|
mch_id = models.CharField(max_length=32, blank=True, default='', verbose_name='收款商户号')
|
||||||
api_v3_key = models.CharField(max_length=128, blank=True, default='')
|
pay_app_id = models.CharField(max_length=32, blank=True, default='', verbose_name='收款AppID')
|
||||||
cert_serial_no = models.CharField(max_length=64, blank=True, default='')
|
mch_key = models.CharField(max_length=128, blank=True, default='', verbose_name='收款APIv2密钥')
|
||||||
private_key_path = models.CharField(max_length=255, blank=True, default='')
|
pay_cert_path = models.CharField(
|
||||||
platform_cert_dir = models.CharField(max_length=255, blank=True, default='')
|
max_length=512, blank=True, default='',
|
||||||
|
verbose_name='收款商户证书路径',
|
||||||
|
help_text='apiclient_cert.pem,退款/V2 证书用',
|
||||||
|
)
|
||||||
|
pay_key_path = models.CharField(
|
||||||
|
max_length=512, blank=True, default='',
|
||||||
|
verbose_name='收款商户私钥路径',
|
||||||
|
help_text='apiclient_key.pem,退款/V2 证书用',
|
||||||
|
)
|
||||||
|
# —— 提现/转账商户(V3;可与收款商户号不同)——
|
||||||
|
withdraw_mch_id = models.CharField(
|
||||||
|
max_length=32, blank=True, default='',
|
||||||
|
verbose_name='提现商户号',
|
||||||
|
help_text='空则回落收款商户号 mch_id',
|
||||||
|
)
|
||||||
|
api_v3_key = models.CharField(max_length=128, blank=True, default='', verbose_name='提现APIv3密钥')
|
||||||
|
cert_serial_no = models.CharField(max_length=64, blank=True, default='', verbose_name='提现证书序列号')
|
||||||
|
private_key_path = models.CharField(
|
||||||
|
max_length=512, blank=True, default='', verbose_name='提现商户私钥路径',
|
||||||
|
)
|
||||||
|
platform_cert_dir = models.CharField(
|
||||||
|
max_length=512, blank=True, default='', verbose_name='提现平台证书目录',
|
||||||
|
)
|
||||||
official_appid = models.CharField(max_length=32, blank=True, default='')
|
official_appid = models.CharField(max_length=32, blank=True, default='')
|
||||||
official_secret = models.CharField(max_length=128, blank=True, default='')
|
official_secret = models.CharField(max_length=128, blank=True, default='')
|
||||||
official_token = models.CharField(max_length=64, blank=True, default='')
|
official_token = models.CharField(max_length=64, blank=True, default='')
|
||||||
@@ -30,6 +52,14 @@ class Club(QModel):
|
|||||||
goeasy_appkey = models.CharField(max_length=64, blank=True, default='')
|
goeasy_appkey = models.CharField(max_length=64, blank=True, default='')
|
||||||
goeasy_secret = models.CharField(max_length=128, blank=True, default='')
|
goeasy_secret = models.CharField(max_length=128, blank=True, default='')
|
||||||
config_json = models.JSONField(default=dict, blank=True)
|
config_json = models.JSONField(default=dict, blank=True)
|
||||||
|
# 小程序「微信支付」入口实际走哪条通道:wechat=直连商户号;fubei=付呗(前端仍传 wechat)
|
||||||
|
mini_pay_channel = models.CharField(
|
||||||
|
max_length=16,
|
||||||
|
blank=True,
|
||||||
|
default='wechat',
|
||||||
|
verbose_name='小程序收款通道',
|
||||||
|
help_text='wechat=微信直连;fubei=付呗;默认 wechat,不影响现网',
|
||||||
|
)
|
||||||
company_uuid = models.BinaryField(max_length=16, null=True, blank=True)
|
company_uuid = models.BinaryField(max_length=16, null=True, blank=True)
|
||||||
sort_order = models.IntegerField(default=0)
|
sort_order = models.IntegerField(default=0)
|
||||||
CreateTime = models.DateTimeField(auto_now_add=True)
|
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||||
@@ -40,6 +70,70 @@ class Club(QModel):
|
|||||||
verbose_name = '俱乐部'
|
verbose_name = '俱乐部'
|
||||||
|
|
||||||
|
|
||||||
|
class ClubPaymentChannel(QModel):
|
||||||
|
"""俱乐部支付通道配置(可多条;同 channel 用 is_default 选主配置)。"""
|
||||||
|
CHANNEL_CHOICES = (
|
||||||
|
('wechat_jsapi', '微信直连-小程序JSAPI'),
|
||||||
|
('wechat_v3', '微信直连-V3转账/退款'),
|
||||||
|
('alipay_wap', '支付宝-手机网站'),
|
||||||
|
('fubei_wx_mini', '付呗-微信小程序'),
|
||||||
|
)
|
||||||
|
|
||||||
|
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部ID')
|
||||||
|
channel = models.CharField(max_length=32, choices=CHANNEL_CHOICES, db_index=True)
|
||||||
|
name = models.CharField(max_length=64, verbose_name='配置名称', help_text='如:主付呗、备用微信')
|
||||||
|
enabled = models.IntegerField(default=1, verbose_name='1启用0停用')
|
||||||
|
is_default = models.BooleanField(default=True, verbose_name='同通道默认配置')
|
||||||
|
sort_order = models.IntegerField(default=0, verbose_name='前端展示顺序')
|
||||||
|
sandbox = models.BooleanField(default=False, verbose_name='沙箱/联调')
|
||||||
|
app_id = models.CharField(max_length=64, blank=True, default='', verbose_name='AppID/付呗ID')
|
||||||
|
mch_id = models.CharField(max_length=64, blank=True, default='', verbose_name='商户号')
|
||||||
|
api_key_enc = models.TextField(blank=True, default='', verbose_name='API密钥/付呗Secret(加密)')
|
||||||
|
private_key_enc = models.TextField(blank=True, default='', verbose_name='应用私钥(加密)')
|
||||||
|
public_key_enc = models.TextField(blank=True, default='', verbose_name='平台公钥(加密或明文)')
|
||||||
|
cert_serial_no = models.CharField(max_length=64, blank=True, default='')
|
||||||
|
private_key_path = models.CharField(max_length=255, blank=True, default='')
|
||||||
|
platform_cert_dir = models.CharField(max_length=255, blank=True, default='')
|
||||||
|
notify_url = models.CharField(max_length=512, blank=True, default='', verbose_name='异步回调URL')
|
||||||
|
return_url = models.CharField(max_length=512, blank=True, default='', verbose_name='同步返回URL')
|
||||||
|
gateway_url = models.CharField(max_length=255, blank=True, default='', verbose_name='API网关')
|
||||||
|
mini_app_id = models.CharField(
|
||||||
|
max_length=32, blank=True, default='',
|
||||||
|
verbose_name='支付用小程序AppID',
|
||||||
|
help_text='付呗/微信JSAPI;空则回落 club.wx_appid',
|
||||||
|
)
|
||||||
|
extra_json = models.JSONField(blank=True, default=dict, verbose_name='扩展配置')
|
||||||
|
remark = models.CharField(max_length=255, blank=True, default='')
|
||||||
|
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||||
|
UpdateTime = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'club_payment_channel'
|
||||||
|
verbose_name = '俱乐部支付通道'
|
||||||
|
unique_together = [['club_id', 'channel', 'name']]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['club_id', 'channel', 'enabled'], name='idx_cpc_club_ch_en'),
|
||||||
|
models.Index(fields=['club_id', 'is_default'], name='idx_cpc_club_default'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class PayChannelLedger(QModel):
|
||||||
|
"""下单时记录实际支付通道,供回调路由与退款选用(前端无感知)。"""
|
||||||
|
out_trade_no = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='商户订单号')
|
||||||
|
club_id = models.CharField(max_length=16, db_index=True, default='')
|
||||||
|
channel = models.CharField(max_length=32, db_index=True, verbose_name='通道')
|
||||||
|
channel_config_id = models.IntegerField(null=True, blank=True)
|
||||||
|
biz_type = models.CharField(max_length=16, blank=True, default='', verbose_name='order/czjilu')
|
||||||
|
provider_order_sn = models.CharField(max_length=64, blank=True, default='', verbose_name='通道侧订单号')
|
||||||
|
amount_yuan = models.DecimalField(max_digits=12, decimal_places=2, default=0)
|
||||||
|
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||||
|
UpdateTime = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'pay_channel_ledger'
|
||||||
|
verbose_name = '支付通道流水'
|
||||||
|
|
||||||
|
|
||||||
class UserWxOpenid(QModel):
|
class UserWxOpenid(QModel):
|
||||||
"""微信 openid 与俱乐部用户绑定(club_id + openid 唯一)。"""
|
"""微信 openid 与俱乐部用户绑定(club_id + openid 唯一)。"""
|
||||||
club_id = models.CharField(max_length=16, db_index=True)
|
club_id = models.CharField(max_length=16, db_index=True)
|
||||||
@@ -316,6 +410,24 @@ class ClubDashouExamPass(QModel):
|
|||||||
unique_together = [['club_id', 'yonghuid']]
|
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):
|
class KefuMenuPage(QModel):
|
||||||
"""客服后台左侧菜单页(固定 page_id,与前端路由对应)。"""
|
"""客服后台左侧菜单页(固定 page_id,与前端路由对应)。"""
|
||||||
page_id = models.CharField(max_length=64, primary_key=True, verbose_name='页面ID')
|
page_id = models.CharField(max_length=64, primary_key=True, verbose_name='页面ID')
|
||||||
@@ -332,3 +444,23 @@ class KefuMenuPage(QModel):
|
|||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'kefu_menu_page'
|
db_table = 'kefu_menu_page'
|
||||||
ordering = ['sort_order', 'page_id']
|
ordering = ['sort_order', 'page_id']
|
||||||
|
|
||||||
|
|
||||||
|
class ClubOrderGrabLink(QModel):
|
||||||
|
"""
|
||||||
|
俱乐部订单单向互通抢单。
|
||||||
|
grabber_club_id 的打手可抢 order_club_id 的待接单;反向需另建一条。
|
||||||
|
关停:删除记录即可(两字段,无开关列)。
|
||||||
|
"""
|
||||||
|
grabber_club_id = models.CharField(max_length=16, db_index=True, verbose_name='抢单方俱乐部')
|
||||||
|
order_club_id = models.CharField(max_length=16, db_index=True, verbose_name='被抢订单所属俱乐部')
|
||||||
|
CreateTime = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'club_order_grab_link'
|
||||||
|
verbose_name = '俱乐部订单互通抢单'
|
||||||
|
unique_together = [['grabber_club_id', 'order_club_id']]
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['grabber_club_id'], name='idx_cogl_grabber'),
|
||||||
|
models.Index(fields=['order_club_id'], name='idx_cogl_order'),
|
||||||
|
]
|
||||||
|
|||||||
@@ -190,27 +190,36 @@ def get_switchable_club_ids(user):
|
|||||||
|
|
||||||
def can_manage_roles_for_club(user, permissions, club_id):
|
def can_manage_roles_for_club(user, permissions, club_id):
|
||||||
"""
|
"""
|
||||||
是否可为指定俱乐部分配/移除功能角色。
|
是否可为指定俱乐部分配/移除功能角色、添加本店后台用户。
|
||||||
集团高层:全部俱乐部;否则须在该俱乐部拥有 000001 超级管理权限。
|
集团高层:全部俱乐部;否则须在该俱乐部拥有 000001 超级管理(不必有集团权限)。
|
||||||
"""
|
"""
|
||||||
if can_manage_admin_assignments(user, permissions):
|
if can_manage_admin_assignments(user, permissions):
|
||||||
return True
|
return True
|
||||||
cid = (club_id or '').strip()
|
cid = (club_id or '').strip()
|
||||||
if not cid:
|
if not cid:
|
||||||
return False
|
return False
|
||||||
from jituan.services.club_rbac import resolve_permission_codes_for_club
|
from jituan.constants import DATA_SCOPE_SINGLE
|
||||||
codes = resolve_permission_codes_for_club(user, cid)
|
from jituan.services.club_rbac import resolve_effective_permission_codes
|
||||||
return '000001' in codes
|
# 与菜单/verify_kefu 同一套:含 legacy,避免「页面能进但添加按钮出不来」
|
||||||
|
codes = resolve_effective_permission_codes(user, cid, DATA_SCOPE_SINGLE)
|
||||||
|
return '000001' in (codes or [])
|
||||||
|
|
||||||
|
|
||||||
def get_manageable_club_ids(user, permissions=None):
|
def get_manageable_club_ids(user, permissions=None, current_club_id=None):
|
||||||
"""当前操作者可管理角色分配的俱乐部集合。"""
|
"""
|
||||||
|
当前操作者可管理角色/后台用户的俱乐部集合。
|
||||||
|
current_club_id:若请求上下文已带 000001,务必把当前顶栏俱乐部算进去。
|
||||||
|
"""
|
||||||
if can_manage_admin_assignments(user, permissions):
|
if can_manage_admin_assignments(user, permissions):
|
||||||
return {c.club_id for c in Club.query.filter(status=1)}
|
return {c.club_id for c in Club.query.filter(status=1)}
|
||||||
return {
|
ids = {
|
||||||
c.club_id for c in Club.query.filter(status=1)
|
c.club_id for c in Club.query.filter(status=1)
|
||||||
if can_manage_roles_for_club(user, permissions, c.club_id)
|
if can_manage_roles_for_club(user, permissions, c.club_id)
|
||||||
}
|
}
|
||||||
|
cid = (current_club_id or '').strip()
|
||||||
|
if cid and permissions and '000001' in permissions:
|
||||||
|
ids.add(cid)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
def can_access_admin_user_management(user, permissions=None):
|
def can_access_admin_user_management(user, permissions=None):
|
||||||
|
|||||||
@@ -18,6 +18,41 @@ from jituan.constants import CLUB_ID_DEFAULT
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ALIPAY_DISABLED_MSG = '支付宝支付功能暂不可用,请使用微信支付'
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_pay_club_id(club_id=None):
|
||||||
|
return (club_id or '').strip().lower() or CLUB_ID_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
def is_alipay_enabled_for_club(club_id=None):
|
||||||
|
"""
|
||||||
|
是否允许该俱乐部「新发起」支付宝支付。
|
||||||
|
仅 settings.ALIPAY_DISABLED_CLUB_IDS 中的俱乐部为 False;其余一律 True。
|
||||||
|
不影响异步回调验签与查单;不影响提现支付宝收款。
|
||||||
|
"""
|
||||||
|
cid = normalize_pay_club_id(club_id)
|
||||||
|
disabled = getattr(settings, 'ALIPAY_DISABLED_CLUB_IDS', None) or ()
|
||||||
|
try:
|
||||||
|
disabled_set = {str(x).strip().lower() for x in disabled if x is not None}
|
||||||
|
except TypeError:
|
||||||
|
disabled_set = set()
|
||||||
|
return cid not in disabled_set
|
||||||
|
|
||||||
|
|
||||||
|
def assert_alipay_allowed_for_club(club_id=None):
|
||||||
|
if not is_alipay_enabled_for_club(club_id):
|
||||||
|
raise Exception(ALIPAY_DISABLED_MSG)
|
||||||
|
|
||||||
|
|
||||||
|
def alipay_pay_method_block_reason(pay_method, club_id=None):
|
||||||
|
"""支付接口入口:仅 pay_method=alipay 且俱乐部禁用时返回文案;微信/其它一律 None。"""
|
||||||
|
if (pay_method or '').strip().lower() != 'alipay':
|
||||||
|
return None
|
||||||
|
if is_alipay_enabled_for_club(club_id):
|
||||||
|
return None
|
||||||
|
return ALIPAY_DISABLED_MSG
|
||||||
|
|
||||||
|
|
||||||
# ==================== 配置读取 ====================
|
# ==================== 配置读取 ====================
|
||||||
|
|
||||||
@@ -33,22 +68,38 @@ def get_alipay_config(club_id=None):
|
|||||||
else:
|
else:
|
||||||
cfg = getattr(settings, 'ALIPAY_PROD', {})
|
cfg = getattr(settings, 'ALIPAY_PROD', {})
|
||||||
|
|
||||||
|
# 充值回调优先读 secrets;空则用 settings 默认地址(绝不回落到订单 NOTIFY_URL)
|
||||||
|
czjilu_notify = (cfg.get('NOTIFY_URL_CZJILU') or '').strip()
|
||||||
|
order_notify = (cfg.get('NOTIFY_URL') or '').strip()
|
||||||
|
default_czjilu = (getattr(settings, 'ALIPAY_CZJILU_NOTIFY_URL', '') or '').strip()
|
||||||
|
if not czjilu_notify or (order_notify and czjilu_notify.rstrip('/') == order_notify.rstrip('/')):
|
||||||
|
czjilu_notify = default_czjilu
|
||||||
return {
|
return {
|
||||||
'APP_ID': cfg.get('APP_ID', ''),
|
'APP_ID': cfg.get('APP_ID', ''),
|
||||||
'PID': cfg.get('PID', ''),
|
'PID': cfg.get('PID', ''),
|
||||||
'APP_PRIVATE_KEY': cfg.get('APP_PRIVATE_KEY', ''),
|
'APP_PRIVATE_KEY': cfg.get('APP_PRIVATE_KEY', ''),
|
||||||
'ALIPAY_PUBLIC_KEY': cfg.get('ALIPAY_PUBLIC_KEY', ''),
|
'ALIPAY_PUBLIC_KEY': cfg.get('ALIPAY_PUBLIC_KEY', ''),
|
||||||
'GATEWAY': cfg.get('GATEWAY', 'https://openapi.alipay.com/gateway.do'),
|
'GATEWAY': cfg.get('GATEWAY', 'https://openapi.alipay.com/gateway.do'),
|
||||||
'NOTIFY_URL': cfg.get('NOTIFY_URL', ''),
|
'NOTIFY_URL': order_notify,
|
||||||
'NOTIFY_URL_CZJILU': cfg.get('NOTIFY_URL_CZJILU', '') or cfg.get('NOTIFY_URL', ''),
|
'NOTIFY_URL_CZJILU': czjilu_notify,
|
||||||
'RETURN_URL': cfg.get('RETURN_URL', ''),
|
'RETURN_URL': cfg.get('RETURN_URL', ''),
|
||||||
'sandbox': sandbox,
|
'sandbox': sandbox,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_czjilu_notify_url(club_id=None):
|
def get_czjilu_notify_url(club_id=None):
|
||||||
"""获取 Czjilu 类充值(押金/积分/会员/商家/考核/罚款)的支付宝异步回调地址。"""
|
"""获取 Czjilu 充值回调地址:secrets 可选覆盖,否则用 settings 写死的正确地址。
|
||||||
return get_alipay_config(club_id)['NOTIFY_URL_CZJILU']
|
|
||||||
|
密钥/APP_ID 仍用原有 ALIPAY_PROD,不必再配新密钥。
|
||||||
|
禁止回落到订单 NOTIFY_URL,避免充值通知被订单回调吞掉。
|
||||||
|
"""
|
||||||
|
cfg = get_alipay_config(club_id)
|
||||||
|
notify_url = (cfg.get('NOTIFY_URL_CZJILU') or '').strip()
|
||||||
|
if not notify_url:
|
||||||
|
notify_url = (getattr(settings, 'ALIPAY_CZJILU_NOTIFY_URL', '') or '').strip()
|
||||||
|
if not notify_url:
|
||||||
|
raise Exception('缺少支付宝充值回调地址 ALIPAY_CZJILU_NOTIFY_URL')
|
||||||
|
return notify_url
|
||||||
|
|
||||||
|
|
||||||
def generate_alipay_czjilu_pay_params(dingdanid, jine, subject, club_id=None, expire_minutes=30):
|
def generate_alipay_czjilu_pay_params(dingdanid, jine, subject, club_id=None, expire_minutes=30):
|
||||||
@@ -57,6 +108,7 @@ def generate_alipay_czjilu_pay_params(dingdanid, jine, subject, club_id=None, ex
|
|||||||
返回 {qr_code, qr_image, pay_method},并在 cache 标记该订单为支付宝订单
|
返回 {qr_code, qr_image, pay_method},并在 cache 标记该订单为支付宝订单
|
||||||
(轮询接口据此跳过微信查单,直接等异步回调更新本地状态)。
|
(轮询接口据此跳过微信查单,直接等异步回调更新本地状态)。
|
||||||
"""
|
"""
|
||||||
|
assert_alipay_allowed_for_club(club_id)
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
notify_url = get_czjilu_notify_url(club_id)
|
notify_url = get_czjilu_notify_url(club_id)
|
||||||
pay_url = build_wap_pay_url(
|
pay_url = build_wap_pay_url(
|
||||||
@@ -66,6 +118,7 @@ def generate_alipay_czjilu_pay_params(dingdanid, jine, subject, club_id=None, ex
|
|||||||
club_id=club_id,
|
club_id=club_id,
|
||||||
expire_minutes=expire_minutes,
|
expire_minutes=expire_minutes,
|
||||||
notify_url=notify_url,
|
notify_url=notify_url,
|
||||||
|
_skip_club_gate=True,
|
||||||
)
|
)
|
||||||
qr_image = qr_link_to_base64(pay_url)
|
qr_image = qr_link_to_base64(pay_url)
|
||||||
cache.set(f'alipay_pay_url_{dingdanid}', pay_url, 60 * 30)
|
cache.set(f'alipay_pay_url_{dingdanid}', pay_url, 60 * 30)
|
||||||
@@ -227,7 +280,10 @@ def verify_notify_params(params, club_id=None):
|
|||||||
|
|
||||||
# ==================== wap.pay 跳转 URL ====================
|
# ==================== wap.pay 跳转 URL ====================
|
||||||
|
|
||||||
def build_wap_pay_url(out_trade_no, amount, subject, body='', club_id=None, expire_minutes=30, notify_url=None):
|
def build_wap_pay_url(
|
||||||
|
out_trade_no, amount, subject, body='', club_id=None, expire_minutes=30,
|
||||||
|
notify_url=None, _skip_club_gate=False,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
生成 alipay.trade.wap.pay 跳转 URL(GET 方式)。
|
生成 alipay.trade.wap.pay 跳转 URL(GET 方式)。
|
||||||
|
|
||||||
@@ -236,13 +292,16 @@ def build_wap_pay_url(out_trade_no, amount, subject, body='', club_id=None, expi
|
|||||||
amount: 订单金额(Decimal 或字符串,单位元)
|
amount: 订单金额(Decimal 或字符串,单位元)
|
||||||
subject: 订单标题
|
subject: 订单标题
|
||||||
body: 订单描述(可选)
|
body: 订单描述(可选)
|
||||||
club_id: 预留多 club(暂未使用)
|
club_id: 俱乐部;命中 ALIPAY_DISABLED_CLUB_IDS 时拒绝发起
|
||||||
expire_minutes: 超时关闭时间(分钟)
|
expire_minutes: 超时关闭时间(分钟)
|
||||||
notify_url: 自定义异步回调地址(可选,默认用 cfg['NOTIFY_URL'])
|
notify_url: 自定义异步回调地址(可选,默认用 cfg['NOTIFY_URL'])
|
||||||
|
_skip_club_gate: 内部已校验过时跳过(避免双重校验)
|
||||||
|
|
||||||
返回:
|
返回:
|
||||||
完整的支付宝跳转 URL,前端 web-view 直接加载即可。
|
完整的支付宝跳转 URL,前端 web-view 直接加载即可。
|
||||||
"""
|
"""
|
||||||
|
if not _skip_club_gate:
|
||||||
|
assert_alipay_allowed_for_club(club_id)
|
||||||
cfg = get_alipay_config(club_id)
|
cfg = get_alipay_config(club_id)
|
||||||
|
|
||||||
biz_content = {
|
biz_content = {
|
||||||
@@ -295,6 +354,7 @@ def build_precreate_qr(out_trade_no, amount, subject, body='', club_id=None, exp
|
|||||||
"""
|
"""
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
assert_alipay_allowed_for_club(club_id)
|
||||||
cfg = get_alipay_config(club_id)
|
cfg = get_alipay_config(club_id)
|
||||||
|
|
||||||
biz_content = {
|
biz_content = {
|
||||||
@@ -387,3 +447,61 @@ def club_id_from_czjilu(dingdan_id):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return CLUB_ID_DEFAULT
|
return CLUB_ID_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== 主动查单(alipay.trade.query)====================
|
||||||
|
|
||||||
|
def query_alipay_trade(out_trade_no, club_id=None):
|
||||||
|
"""主动查询支付宝交易状态。
|
||||||
|
|
||||||
|
调用 alipay.trade.query 接口,返回 trade_status 字符串:
|
||||||
|
WAIT_BUYER_PAY / TRADE_CLOSED / TRADE_SUCCESS / TRADE_FINISHED
|
||||||
|
查询失败或异常返回空字符串。
|
||||||
|
|
||||||
|
用于轮询补偿:支付宝异步回调未到达时,通过主动查单确认支付状态。
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
|
||||||
|
cfg = get_alipay_config(club_id)
|
||||||
|
|
||||||
|
biz_content = {
|
||||||
|
'out_trade_no': out_trade_no,
|
||||||
|
}
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'app_id': cfg['APP_ID'],
|
||||||
|
'method': 'alipay.trade.query',
|
||||||
|
'charset': 'utf-8',
|
||||||
|
'sign_type': 'RSA2',
|
||||||
|
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||||
|
'version': '1.0',
|
||||||
|
'biz_content': json.dumps(biz_content, separators=(',', ':'), ensure_ascii=False),
|
||||||
|
}
|
||||||
|
|
||||||
|
sign_content = _build_sign_content(params)
|
||||||
|
params['sign'] = sign_rsa2(sign_content, cfg['APP_PRIVATE_KEY'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(cfg['GATEWAY'], data=params, timeout=10)
|
||||||
|
result = resp.json()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('支付宝查单请求异常 out_trade_no=%s: %s', out_trade_no, exc, exc_info=True)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
biz_resp = result.get('alipay_trade_query_response', {})
|
||||||
|
code = biz_resp.get('code', '')
|
||||||
|
trade_status = biz_resp.get('trade_status', '')
|
||||||
|
|
||||||
|
if code == '10000':
|
||||||
|
logger.info('支付宝查单成功 out_trade_no=%s trade_status=%s', out_trade_no, trade_status)
|
||||||
|
return trade_status
|
||||||
|
|
||||||
|
# 40004 = 交易不存在(订单未在支付宝侧创建或已关闭)
|
||||||
|
sub_code = biz_resp.get('sub_code', '')
|
||||||
|
sub_msg = biz_resp.get('sub_msg', '')
|
||||||
|
logger.warning(
|
||||||
|
'支付宝查单返回非成功 out_trade_no=%s code=%s msg=%s sub_code=%s sub_msg=%s',
|
||||||
|
out_trade_no, code, biz_resp.get('msg', ''), sub_code, sub_msg,
|
||||||
|
)
|
||||||
|
return trade_status or ''
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
"""集团全局商品目录(商品/类型/专区)须在集团汇总视图操作。"""
|
"""商品与专区按俱乐部隔离;类型仍可走集团汇总。"""
|
||||||
from jituan.constants import DATA_SCOPE_ALL
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
from jituan.services.club_context import resolve_club_scope
|
from jituan.services.club_context import resolve_club_scope
|
||||||
|
|
||||||
GROUP_CATALOG_SCOPE_MSG = (
|
GROUP_CATALOG_SCOPE_MSG = (
|
||||||
'商品、类型与专区为集团全局数据,请在顶栏切换为「集团汇总(全部子公司)」后操作;'
|
'商品类型为集团全局数据,请在顶栏切换为「集团汇总(全部子公司)」后操作;'
|
||||||
'具体小程序仅可在「小程序配置」中做上架/图标等俱乐部侧配置。'
|
'商品与专区已按俱乐部隔离,切换对应俱乐部即可管理。'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def block_non_group_catalog_scope(request):
|
def block_non_group_catalog_scope(request):
|
||||||
"""非集团汇总视图时返回错误文案,否则返回 None。"""
|
"""非集团汇总视图时返回错误文案,否则返回 None。用于类型等全局目录。"""
|
||||||
if resolve_club_scope(request) != DATA_SCOPE_ALL:
|
if resolve_club_scope(request) != DATA_SCOPE_ALL:
|
||||||
return GROUP_CATALOG_SCOPE_MSG
|
return GROUP_CATALOG_SCOPE_MSG
|
||||||
return None
|
return None
|
||||||
|
|||||||
120
jituan/services/club_cert_upload.py
Normal file
120
jituan/services/club_cert_upload.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""俱乐部微信商户证书落盘(收款 / 提现分目录)。"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# pay = 点单收款商户;withdraw = 提现转账商户
|
||||||
|
CERT_KINDS = {
|
||||||
|
'pay_key': ('pay', 'apiclient_key.pem', 'pay_key_path'),
|
||||||
|
'pay_cert': ('pay', 'apiclient_cert.pem', 'pay_cert_path'),
|
||||||
|
'withdraw_key': ('withdraw', 'apiclient_key.pem', 'private_key_path'),
|
||||||
|
'withdraw_cert': ('withdraw', 'apiclient_cert.pem', None), # 与私钥同目录,不单独存字段
|
||||||
|
'withdraw_platform': ('withdraw', 'pub_key.pem', 'platform_cert_dir'), # 目录字段指向所在目录
|
||||||
|
}
|
||||||
|
|
||||||
|
_SAFE_CLUB = re.compile(r'^[a-zA-Z0-9_-]{1,16}$')
|
||||||
|
|
||||||
|
|
||||||
|
def club_wx_cert_root():
|
||||||
|
"""
|
||||||
|
证书根目录。优先 settings.CLUB_WX_CERT_ROOT;
|
||||||
|
否则尝试沿用现网 secrets 父目录下的 wx_certs;最后 BASE_DIR/wx_certs。
|
||||||
|
"""
|
||||||
|
configured = (getattr(settings, 'CLUB_WX_CERT_ROOT', None) or '').strip()
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
# 兼容现网:.../sites/xxx/tuikuanzhengshu → .../sites/xxx/wx_certs
|
||||||
|
for attr in ('WEIXIN_CERT_PATH', 'WEIXIN_KEY_PATH'):
|
||||||
|
p = (getattr(settings, attr, None) or '').strip()
|
||||||
|
if p:
|
||||||
|
parent = os.path.dirname(os.path.dirname(os.path.abspath(p)))
|
||||||
|
if parent and parent not in ('.', os.sep):
|
||||||
|
return os.path.join(parent, 'wx_certs')
|
||||||
|
v3 = getattr(settings, 'WECHAT_PAY_V3_CONFIG', None) or {}
|
||||||
|
pk = (v3.get('PRIVATE_KEY_PATH') or '').strip()
|
||||||
|
if pk:
|
||||||
|
parent = os.path.dirname(os.path.dirname(os.path.abspath(pk)))
|
||||||
|
if parent and parent not in ('.', os.sep):
|
||||||
|
return os.path.join(parent, 'wx_certs')
|
||||||
|
return os.path.join(str(settings.BASE_DIR), 'wx_certs')
|
||||||
|
|
||||||
|
|
||||||
|
def club_cert_dir(club_id, bucket):
|
||||||
|
cid = (club_id or '').strip()
|
||||||
|
if not _SAFE_CLUB.match(cid):
|
||||||
|
raise ValueError('非法 club_id')
|
||||||
|
if bucket not in ('pay', 'withdraw'):
|
||||||
|
raise ValueError('非法证书分类')
|
||||||
|
path = os.path.join(club_wx_cert_root(), cid, bucket)
|
||||||
|
os.makedirs(path, mode=0o750, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_pem(raw: bytes, expect_private: bool):
|
||||||
|
if not raw or len(raw) > 64 * 1024:
|
||||||
|
raise ValueError('文件过大或为空')
|
||||||
|
text = raw.decode('utf-8', errors='ignore')
|
||||||
|
if 'BEGIN' not in text or 'END' not in text:
|
||||||
|
raise ValueError('不是有效的 PEM 文件')
|
||||||
|
has_private = 'PRIVATE KEY' in text
|
||||||
|
if expect_private and not has_private:
|
||||||
|
raise ValueError('需要私钥 PEM(含 PRIVATE KEY)')
|
||||||
|
if not expect_private and has_private:
|
||||||
|
raise ValueError('请上传证书/公钥 PEM,不要上传私钥')
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def save_club_cert_file(club, kind, uploaded_file):
|
||||||
|
"""
|
||||||
|
保存上传的证书文件并回写 Club 字段。
|
||||||
|
kind: pay_key | pay_cert | withdraw_key | withdraw_cert | withdraw_platform
|
||||||
|
返回 {'path': ..., 'field': ..., 'kind': ...}
|
||||||
|
"""
|
||||||
|
if kind not in CERT_KINDS:
|
||||||
|
raise ValueError(f'不支持的文件类型: {kind}')
|
||||||
|
bucket, filename, field_name = CERT_KINDS[kind]
|
||||||
|
expect_private = kind in ('pay_key', 'withdraw_key')
|
||||||
|
raw = uploaded_file.read()
|
||||||
|
raw = _validate_pem(raw, expect_private=expect_private)
|
||||||
|
|
||||||
|
directory = club_cert_dir(club.club_id, bucket)
|
||||||
|
dest = os.path.join(directory, filename)
|
||||||
|
# 原子写入
|
||||||
|
tmp = dest + '.tmp'
|
||||||
|
with open(tmp, 'wb') as f:
|
||||||
|
f.write(raw)
|
||||||
|
os.chmod(tmp, 0o640)
|
||||||
|
os.replace(tmp, dest)
|
||||||
|
|
||||||
|
update_fields = []
|
||||||
|
if field_name == 'platform_cert_dir':
|
||||||
|
# 平台证书:字段存目录
|
||||||
|
setattr(club, field_name, directory)
|
||||||
|
update_fields.append(field_name)
|
||||||
|
saved_path = directory
|
||||||
|
elif field_name:
|
||||||
|
setattr(club, field_name, dest)
|
||||||
|
update_fields.append(field_name)
|
||||||
|
saved_path = dest
|
||||||
|
else:
|
||||||
|
# withdraw_cert:只落盘,序列号由私钥配对自动解析
|
||||||
|
saved_path = dest
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
club.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
'[CLUB_CERT_UPLOAD] club=%s kind=%s path=%s field=%s',
|
||||||
|
club.club_id, kind, saved_path, field_name or '(none)',
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'kind': kind,
|
||||||
|
'path': saved_path,
|
||||||
|
'field': field_name,
|
||||||
|
'bucket': bucket,
|
||||||
|
'filename': filename,
|
||||||
|
}
|
||||||
@@ -128,13 +128,17 @@ def build_member_list_payload(request):
|
|||||||
for m in members:
|
for m in members:
|
||||||
member_list.append(_format_member(m))
|
member_list.append(_format_member(m))
|
||||||
else:
|
else:
|
||||||
|
# 有本俱乐部专属价:只展示本店上架项;尚无专属价:回落集团默认会员定义。
|
||||||
|
# 绝不混入其他俱乐部的 ClubHuiyuanPrice。
|
||||||
|
has_club_prices = bool(price_map)
|
||||||
for m in members:
|
for m in members:
|
||||||
row = price_map.get(m.huiyuan_id)
|
row = price_map.get(m.huiyuan_id)
|
||||||
if row is None and club_id != CLUB_ID_DEFAULT:
|
if has_club_prices:
|
||||||
continue
|
if row is None or not row.is_enabled:
|
||||||
if row is not None and not row.is_enabled:
|
continue
|
||||||
continue
|
item = _format_member(m, row, club_id=club_id)
|
||||||
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)
|
item['goumai_cishu'] = _sales_map.get(m.huiyuan_id, 0)
|
||||||
member_list.append(item)
|
member_list.append(item)
|
||||||
|
|
||||||
@@ -143,7 +147,7 @@ def build_member_list_payload(request):
|
|||||||
'club_id': club_id,
|
'club_id': club_id,
|
||||||
'scope': scope,
|
'scope': scope,
|
||||||
'price_note': (
|
'price_note': (
|
||||||
f'当前俱乐部 {club_id} 专属售价(club_huiyuan_price)。'
|
f'当前俱乐部 {club_id} 专属售价;未配置专属价时展示集团默认会员。'
|
||||||
if scope != DATA_SCOPE_ALL else
|
if scope != DATA_SCOPE_ALL else
|
||||||
'集团汇总:展示全局默认价;切换具体俱乐部可配置售价与体验会员。'
|
'集团汇总:展示全局默认价;切换具体俱乐部可配置售价与体验会员。'
|
||||||
),
|
),
|
||||||
@@ -272,9 +276,13 @@ MEMBER_CARD_FOLDER = 'beijing/huiyuan'
|
|||||||
|
|
||||||
|
|
||||||
def upload_member_card_image(request, huiyuan_id, field, file_obj):
|
def upload_member_card_image(request, huiyuan_id, field, file_obj):
|
||||||
"""上传会员充值页卡片图,写入 club_huiyuan_price.card_image / card_bg。"""
|
"""上传会员充值页卡片图,写入 club_huiyuan_price.card_image / card_bg。
|
||||||
|
|
||||||
|
每次上传生成新相对路径并删除旧图,避免「同名覆盖但 CDN/浏览器仍显示旧图」。
|
||||||
|
"""
|
||||||
import os
|
import os
|
||||||
from utils.oss_utils import validate_image, upload_to_oss
|
import uuid
|
||||||
|
from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss
|
||||||
|
|
||||||
if club_write_blocked(request):
|
if club_write_blocked(request):
|
||||||
return None, CLUB_WRITE_SCOPE_MSG
|
return None, CLUB_WRITE_SCOPE_MSG
|
||||||
@@ -285,18 +293,36 @@ def upload_member_card_image(request, huiyuan_id, field, file_obj):
|
|||||||
return None, msg
|
return None, msg
|
||||||
|
|
||||||
club_id = club_id_for_write(request)
|
club_id = club_id_for_write(request)
|
||||||
ext = os.path.splitext(getattr(file_obj, 'name', '') or '')[1].lower()
|
|
||||||
if ext not in ('.png', '.jpg', '.jpeg', '.webp', '.gif'):
|
|
||||||
ext = '.png'
|
|
||||||
rel_path = f'{MEMBER_CARD_FOLDER}/{club_id}/{huiyuan_id}_{field}{ext}'
|
|
||||||
if not upload_to_oss(file_obj, rel_path):
|
|
||||||
return None, '上传 OSS 失败'
|
|
||||||
|
|
||||||
row = ClubHuiyuanPrice.query.filter(club_id=club_id, huiyuan_id=huiyuan_id).first()
|
row = ClubHuiyuanPrice.query.filter(club_id=club_id, huiyuan_id=huiyuan_id).first()
|
||||||
if not row:
|
if not row:
|
||||||
return None, '请先在当前俱乐部上架该会员'
|
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)
|
setattr(row, field, rel_path)
|
||||||
row.save(update_fields=[field, 'UpdateTime'])
|
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
|
return {field: rel_path, 'huiyuan_id': huiyuan_id}, None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
246
jituan/services/club_payment_channel.py
Normal file
246
jituan/services/club_payment_channel.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
"""俱乐部支付通道:读配置、加解密、付呗/微信/支付宝字段归一化。"""
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
from gvsdsdk.payment.models import EncryptField, _decrypt_field
|
||||||
|
|
||||||
|
from jituan.constants import (
|
||||||
|
CLUB_ID_DEFAULT,
|
||||||
|
FUBEI_GATEWAY_PROD,
|
||||||
|
FUBEI_GATEWAY_SANDBOX,
|
||||||
|
PAY_CHANNEL_ALIPAY_WAP,
|
||||||
|
PAY_CHANNEL_FUBEI_WX_MINI,
|
||||||
|
PAY_CHANNEL_WECHAT_JSAPI,
|
||||||
|
PAY_METHOD_ALIPAY,
|
||||||
|
PAY_METHOD_FUBEI,
|
||||||
|
PAY_METHOD_LABELS,
|
||||||
|
PAY_METHOD_TO_CHANNEL,
|
||||||
|
PAY_METHOD_WECHAT,
|
||||||
|
)
|
||||||
|
from jituan.models import Club, ClubPaymentChannel
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CACHE_TTL = 60
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret_field(plain: str) -> str:
|
||||||
|
if not plain:
|
||||||
|
return ''
|
||||||
|
return EncryptField(plain)
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret_field(encrypted: str) -> str:
|
||||||
|
if not encrypted:
|
||||||
|
return ''
|
||||||
|
return _decrypt_field(encrypted)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PaymentChannelConfig:
|
||||||
|
"""归一化后的通道配置,供支付网关读取。"""
|
||||||
|
club_id: str
|
||||||
|
channel: str
|
||||||
|
config_id: int
|
||||||
|
name: str
|
||||||
|
enabled: bool
|
||||||
|
sandbox: bool
|
||||||
|
app_id: str = ''
|
||||||
|
mch_id: str = ''
|
||||||
|
api_key: str = ''
|
||||||
|
private_key: str = ''
|
||||||
|
public_key: str = ''
|
||||||
|
cert_serial_no: str = ''
|
||||||
|
private_key_path: str = ''
|
||||||
|
platform_cert_dir: str = ''
|
||||||
|
notify_url: str = ''
|
||||||
|
return_url: str = ''
|
||||||
|
gateway_url: str = ''
|
||||||
|
mini_app_id: str = ''
|
||||||
|
extra: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def store_id(self):
|
||||||
|
return self.extra.get('store_id') or self.extra.get('fubei_store_id')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def fubei_vendor_sn(self):
|
||||||
|
return self.extra.get('vendor_sn') or self.extra.get('fubei_vendor_sn') or self.app_id
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(club_id: str, channel: str) -> str:
|
||||||
|
return f'club_pay_ch:{club_id}:{channel}'
|
||||||
|
|
||||||
|
|
||||||
|
def _default_notify_path(channel: str) -> str:
|
||||||
|
if channel == PAY_CHANNEL_FUBEI_WX_MINI:
|
||||||
|
return '/hqhd/dingdan/fubei-notify/'
|
||||||
|
if channel == PAY_CHANNEL_ALIPAY_WAP:
|
||||||
|
return '/hqhd/dingdan/alipay-notify/'
|
||||||
|
return '/hqhd/dingdan/pay-notify/'
|
||||||
|
|
||||||
|
|
||||||
|
def _build_base_url(club: Club) -> str:
|
||||||
|
domain = (club.h5_domain or '').strip().rstrip('/')
|
||||||
|
if domain:
|
||||||
|
return domain
|
||||||
|
return 'https://www.abas.asia'
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_notify_url(club: Club, row: ClubPaymentChannel) -> str:
|
||||||
|
if row.notify_url:
|
||||||
|
return row.notify_url.strip()
|
||||||
|
return f'{_build_base_url(club)}{_default_notify_path(row.channel)}'
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_config(club: Club, row: ClubPaymentChannel) -> PaymentChannelConfig:
|
||||||
|
extra = dict(row.extra_json or {})
|
||||||
|
gateway = (row.gateway_url or '').strip()
|
||||||
|
if not gateway and row.channel == PAY_CHANNEL_FUBEI_WX_MINI:
|
||||||
|
gateway = FUBEI_GATEWAY_SANDBOX if row.sandbox else FUBEI_GATEWAY_PROD
|
||||||
|
|
||||||
|
mini_app_id = (row.mini_app_id or club.pay_app_id or club.wx_appid or '').strip()
|
||||||
|
|
||||||
|
return PaymentChannelConfig(
|
||||||
|
club_id=row.club_id,
|
||||||
|
channel=row.channel,
|
||||||
|
config_id=row.id,
|
||||||
|
name=row.name,
|
||||||
|
enabled=row.enabled == 1,
|
||||||
|
sandbox=bool(row.sandbox),
|
||||||
|
app_id=(row.app_id or '').strip(),
|
||||||
|
mch_id=(row.mch_id or '').strip(),
|
||||||
|
api_key=decrypt_secret_field(row.api_key_enc),
|
||||||
|
private_key=decrypt_secret_field(row.private_key_enc),
|
||||||
|
public_key=decrypt_secret_field(row.public_key_enc) or (row.public_key_enc or '').strip(),
|
||||||
|
cert_serial_no=row.cert_serial_no or '',
|
||||||
|
private_key_path=row.private_key_path or '',
|
||||||
|
platform_cert_dir=row.platform_cert_dir or '',
|
||||||
|
notify_url=_resolve_notify_url(club, row),
|
||||||
|
return_url=(row.return_url or '').strip(),
|
||||||
|
gateway_url=gateway,
|
||||||
|
mini_app_id=mini_app_id,
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_club_payment_channel(
|
||||||
|
club_id: Optional[str],
|
||||||
|
channel: str,
|
||||||
|
*,
|
||||||
|
config_id: Optional[int] = None,
|
||||||
|
require_enabled: bool = True,
|
||||||
|
) -> Optional[PaymentChannelConfig]:
|
||||||
|
"""读取俱乐部某通道配置;优先 is_default,可指定 config_id。"""
|
||||||
|
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||||
|
cache_k = _cache_key(cid, channel)
|
||||||
|
if config_id is None and require_enabled:
|
||||||
|
cached = cache.get(cache_k)
|
||||||
|
if cached is not None:
|
||||||
|
return cached if cached != '__none__' else None
|
||||||
|
|
||||||
|
club = Club.query.filter(club_id=cid).first()
|
||||||
|
if not club:
|
||||||
|
logger.warning('club %s 不存在,无法读取支付通道 %s', cid, channel)
|
||||||
|
return None
|
||||||
|
|
||||||
|
qs = ClubPaymentChannel.query.filter(club_id=cid, channel=channel)
|
||||||
|
if require_enabled:
|
||||||
|
qs = qs.filter(enabled=1)
|
||||||
|
if config_id is not None:
|
||||||
|
row = qs.filter(id=config_id).first()
|
||||||
|
else:
|
||||||
|
row = qs.filter(is_default=True).order_by('sort_order', 'id').first()
|
||||||
|
if not row:
|
||||||
|
row = qs.order_by('sort_order', 'id').first()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
if config_id is None and require_enabled:
|
||||||
|
cache.set(cache_k, '__none__', CACHE_TTL)
|
||||||
|
return None
|
||||||
|
|
||||||
|
cfg = _row_to_config(club, row)
|
||||||
|
if config_id is None and require_enabled:
|
||||||
|
cache.set(cache_k, cfg, CACHE_TTL)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_club_payment_cache(club_id: str, channel: Optional[str] = None):
|
||||||
|
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||||
|
if channel:
|
||||||
|
cache.delete(_cache_key(cid, channel))
|
||||||
|
return
|
||||||
|
for ch in (
|
||||||
|
PAY_CHANNEL_WECHAT_JSAPI,
|
||||||
|
PAY_CHANNEL_ALIPAY_WAP,
|
||||||
|
PAY_CHANNEL_FUBEI_WX_MINI,
|
||||||
|
):
|
||||||
|
cache.delete(_cache_key(cid, ch))
|
||||||
|
|
||||||
|
|
||||||
|
def get_fubei_config(club_id: Optional[str] = None) -> Optional[PaymentChannelConfig]:
|
||||||
|
"""付呗微信小程序通道配置。"""
|
||||||
|
return get_club_payment_channel(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
|
||||||
|
|
||||||
|
|
||||||
|
def list_payment_methods_for_miniapp(club_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""小程序下单页:返回用户可见的支付方式(enabled 且配置完整)。"""
|
||||||
|
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||||
|
methods = []
|
||||||
|
|
||||||
|
wechat_direct = get_club_payment_channel(cid, PAY_CHANNEL_WECHAT_JSAPI)
|
||||||
|
fubei = get_fubei_config(cid)
|
||||||
|
alipay = get_club_payment_channel(cid, PAY_CHANNEL_ALIPAY_WAP)
|
||||||
|
|
||||||
|
if fubei and fubei.app_id and fubei.api_key:
|
||||||
|
methods.append({
|
||||||
|
'code': PAY_METHOD_FUBEI,
|
||||||
|
'label': PAY_METHOD_LABELS[PAY_METHOD_FUBEI],
|
||||||
|
'channel': PAY_CHANNEL_FUBEI_WX_MINI,
|
||||||
|
'sort_order': _sort_for(cid, PAY_CHANNEL_FUBEI_WX_MINI),
|
||||||
|
})
|
||||||
|
elif wechat_direct and wechat_direct.mch_id and wechat_direct.api_key:
|
||||||
|
methods.append({
|
||||||
|
'code': PAY_METHOD_WECHAT,
|
||||||
|
'label': PAY_METHOD_LABELS[PAY_METHOD_WECHAT],
|
||||||
|
'channel': PAY_CHANNEL_WECHAT_JSAPI,
|
||||||
|
'sort_order': _sort_for(cid, PAY_CHANNEL_WECHAT_JSAPI),
|
||||||
|
})
|
||||||
|
|
||||||
|
if alipay and alipay.app_id and alipay.private_key and alipay.public_key:
|
||||||
|
from jituan.services.alipay_pay import is_alipay_enabled_for_club
|
||||||
|
if is_alipay_enabled_for_club(cid):
|
||||||
|
methods.append({
|
||||||
|
'code': PAY_METHOD_ALIPAY,
|
||||||
|
'label': PAY_METHOD_LABELS[PAY_METHOD_ALIPAY],
|
||||||
|
'channel': PAY_CHANNEL_ALIPAY_WAP,
|
||||||
|
'sort_order': _sort_for(cid, PAY_CHANNEL_ALIPAY_WAP),
|
||||||
|
})
|
||||||
|
|
||||||
|
methods.sort(key=lambda x: x.get('sort_order', 0))
|
||||||
|
return methods
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_for(club_id: str, channel: str) -> int:
|
||||||
|
row = ClubPaymentChannel.query.filter(
|
||||||
|
club_id=club_id, channel=channel, enabled=1,
|
||||||
|
).order_by('-is_default', 'sort_order', 'id').first()
|
||||||
|
return row.sort_order if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def channel_for_pay_method(pay_method: str) -> Optional[str]:
|
||||||
|
return PAY_METHOD_TO_CHANNEL.get((pay_method or '').strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
# 付呗通道后台必填字段说明(文档/校验用)
|
||||||
|
FUBEI_REQUIRED_FIELDS = {
|
||||||
|
'app_id': '付呗开放平台 vendor_sn / 付呗ID',
|
||||||
|
'api_key_enc': '付呗 Secret(入库前加密)',
|
||||||
|
'mini_app_id': '微信小程序 AppID(可与 club.wx_appid 相同;须在付呗后台关联)',
|
||||||
|
'notify_url': '异步回调,默认 /hqhd/dingdan/fubei-notify/',
|
||||||
|
'extra_json.store_id': '付呗门店 store_id(服务商开户后提供,统一下单必填)',
|
||||||
|
}
|
||||||
@@ -13,26 +13,44 @@ def _global_role_q():
|
|||||||
|
|
||||||
|
|
||||||
def resolve_role_club_id(request, explicit=None):
|
def resolve_role_club_id(request, explicit=None):
|
||||||
"""创建/筛选角色时使用的俱乐部;集团视图可显式指定 target_club_id。"""
|
"""
|
||||||
|
创建/筛选角色时使用的俱乐部。
|
||||||
|
单俱乐部视图:强制当前顶栏俱乐部,忽略跨店 target_club_id。
|
||||||
|
集团汇总视图:可显式指定;未指定则为全局(club_id 空)。
|
||||||
|
"""
|
||||||
|
scope = resolve_club_scope(request)
|
||||||
|
if scope != DATA_SCOPE_ALL:
|
||||||
|
return resolve_club_id_from_request(request)
|
||||||
if explicit:
|
if explicit:
|
||||||
return (explicit or '').strip()
|
return (explicit or '').strip()
|
||||||
scope = resolve_club_scope(request)
|
return ''
|
||||||
if scope == DATA_SCOPE_ALL:
|
|
||||||
return ''
|
|
||||||
return resolve_club_id_from_request(request)
|
|
||||||
|
|
||||||
|
|
||||||
def filter_roles_for_request(request):
|
def filter_roles_for_request(request):
|
||||||
"""
|
"""
|
||||||
子公司视图:仅本俱乐部角色。
|
子公司视图:优先本俱乐部角色;本俱乐部尚无角色时仅回落集团全局角色。
|
||||||
集团汇总视图:全部角色(含各俱乐部 + 全局 club_id 为空)。
|
绝不混入其他俱乐部角色。
|
||||||
|
集团汇总视图:全部角色(含各俱乐部 + 全局)。
|
||||||
"""
|
"""
|
||||||
qs = Role.objects.filter(RoleStatus=1)
|
qs = Role.objects.filter(RoleStatus=1)
|
||||||
scope = resolve_club_scope(request)
|
scope = resolve_club_scope(request)
|
||||||
if scope == DATA_SCOPE_ALL:
|
if scope == DATA_SCOPE_ALL:
|
||||||
return qs
|
return qs
|
||||||
club_id = resolve_club_id_from_request(request)
|
club_id = resolve_club_id_from_request(request)
|
||||||
return qs.filter(Q(ClubID=club_id) | _global_role_q())
|
club_qs = qs.filter(ClubID=club_id)
|
||||||
|
if club_qs.exists():
|
||||||
|
return club_qs
|
||||||
|
return qs.filter(_global_role_q())
|
||||||
|
|
||||||
|
|
||||||
|
def role_visible_in_scope(role_club_id, request):
|
||||||
|
"""列表展示用:单俱乐部视图只允许本俱乐部或全局(无本俱乐部角色时由调用方决定)。"""
|
||||||
|
scope = resolve_club_scope(request)
|
||||||
|
if scope == DATA_SCOPE_ALL:
|
||||||
|
return True
|
||||||
|
curr = (resolve_club_id_from_request(request) or '').strip()
|
||||||
|
cid = (role_club_id or '').strip()
|
||||||
|
return cid == '' or cid == curr
|
||||||
|
|
||||||
|
|
||||||
def filter_kefu_users_by_admin_scope(qs, request):
|
def filter_kefu_users_by_admin_scope(qs, request):
|
||||||
|
|||||||
@@ -13,25 +13,45 @@ logger = logging.getLogger(__name__)
|
|||||||
_REFERER_APPID_RE = re.compile(r'servicewechat\.com/(wx[0-9a-fA-F]+)/', re.I)
|
_REFERER_APPID_RE = re.compile(r'servicewechat\.com/(wx[0-9a-fA-F]+)/', re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def get_club_miniapp_appid(club):
|
||||||
|
"""小程序 AppID:登录 jscode2session 与 JSAPI 支付须一致。"""
|
||||||
|
if not club:
|
||||||
|
return ''
|
||||||
|
return (club.wx_appid or club.pay_app_id or '').strip()
|
||||||
|
|
||||||
|
|
||||||
def resolve_club_by_appid(app_id):
|
def resolve_club_by_appid(app_id):
|
||||||
if not app_id:
|
if not app_id:
|
||||||
return None
|
return None
|
||||||
|
app_id = app_id.strip()
|
||||||
try:
|
try:
|
||||||
return Club.query.filter(wx_appid=app_id, status=1).first()
|
club = Club.query.filter(wx_appid=app_id, status=1).first()
|
||||||
|
if club:
|
||||||
|
return club
|
||||||
|
return Club.query.filter(pay_app_id=app_id, status=1).first()
|
||||||
except Exception:
|
except Exception:
|
||||||
return Club.objects.filter(wx_appid=app_id, status=1).first()
|
club = Club.objects.filter(wx_appid=app_id, status=1).first()
|
||||||
|
if club:
|
||||||
|
return club
|
||||||
|
return Club.objects.filter(pay_app_id=app_id, status=1).first()
|
||||||
|
|
||||||
|
|
||||||
def get_club_wx_credentials(club_id):
|
def get_club_wx_credentials(club_id):
|
||||||
"""优先读 club 表;xq 缺省时回退 settings 全局配置(兼容现网)。"""
|
"""优先读 club 表;xq 缺省时回退 settings 全局配置(兼容现网)。"""
|
||||||
club_id = club_id or CLUB_ID_DEFAULT
|
club_id = club_id or CLUB_ID_DEFAULT
|
||||||
|
club = None
|
||||||
try:
|
try:
|
||||||
club = Club.query.get(club_id=club_id)
|
club = Club.query.get(club_id=club_id)
|
||||||
except Club.DoesNotExist:
|
except Club.DoesNotExist:
|
||||||
club = None
|
club = None
|
||||||
|
except Exception:
|
||||||
|
logger.exception('读取俱乐部失败 club_id=%s', club_id)
|
||||||
|
club = None
|
||||||
|
|
||||||
if club and club.wx_appid and club.wx_secret:
|
appid = get_club_miniapp_appid(club)
|
||||||
return club.wx_appid, club.wx_secret, club
|
secret = (club.wx_secret or '').strip() if club else ''
|
||||||
|
if appid and secret:
|
||||||
|
return appid, secret, club
|
||||||
|
|
||||||
if club_id == CLUB_ID_DEFAULT:
|
if club_id == CLUB_ID_DEFAULT:
|
||||||
return (
|
return (
|
||||||
@@ -39,7 +59,10 @@ def get_club_wx_credentials(club_id):
|
|||||||
getattr(settings, 'WEIXIN_SECRET', ''),
|
getattr(settings, 'WEIXIN_SECRET', ''),
|
||||||
club,
|
club,
|
||||||
)
|
)
|
||||||
return '', '', club
|
# 非默认俱乐部未配置时给出明确原因,避免后续空指针 500
|
||||||
|
if not club:
|
||||||
|
return '', '', None
|
||||||
|
return appid or '', secret or '', club
|
||||||
|
|
||||||
|
|
||||||
def _invite_code_from_request(request):
|
def _invite_code_from_request(request):
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
"""用户所属俱乐部解析。"""
|
"""用户所属俱乐部解析。"""
|
||||||
|
import logging
|
||||||
|
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
|
|
||||||
from jituan.constants import CLUB_ID_DEFAULT
|
from jituan.constants import CLUB_ID_DEFAULT
|
||||||
from jituan.models import UserWxOpenid
|
from jituan.models import UserWxOpenid
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def resolve_invite_party_club(user, request_club_id=None):
|
def resolve_invite_party_club(user, request_club_id=None):
|
||||||
"""
|
"""
|
||||||
@@ -106,18 +110,35 @@ def ensure_wx_openid_binding(club_id, openid, user, unionid=''):
|
|||||||
openid = (openid or '').strip()
|
openid = (openid or '').strip()
|
||||||
|
|
||||||
binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
|
binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
|
||||||
if binding:
|
if binding and binding.yonghuid == uid:
|
||||||
return binding
|
return binding
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 独立 savepoint:并发/重复绑定时不污染外层 transaction.atomic
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
return UserWxOpenid.query.create(
|
# 同一用户在同一俱乐部只保留当前 openid(AppID 变更/重新登录须覆盖旧绑定)
|
||||||
|
stale = UserWxOpenid.query.filter(yonghuid=uid, club_id=club_id).exclude(openid=openid)
|
||||||
|
stale_count = stale.count()
|
||||||
|
if stale_count:
|
||||||
|
logger.info(
|
||||||
|
'[WX_OPENID_BIND] uid=%s club=%s 清除 %d 条过期 openid 绑定',
|
||||||
|
uid, club_id, stale_count,
|
||||||
|
)
|
||||||
|
stale.delete()
|
||||||
|
|
||||||
|
binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
|
||||||
|
if binding:
|
||||||
|
return binding
|
||||||
|
|
||||||
|
row = UserWxOpenid.query.create(
|
||||||
club_id=club_id,
|
club_id=club_id,
|
||||||
openid=openid,
|
openid=openid,
|
||||||
yonghuid=uid,
|
yonghuid=uid,
|
||||||
unionid=unionid or getattr(user, 'UnionID', None) or '',
|
unionid=unionid or getattr(user, 'UnionID', None) or '',
|
||||||
)
|
)
|
||||||
|
if club_id == CLUB_ID_DEFAULT and getattr(user, 'OpenID', None) != openid:
|
||||||
|
user.OpenID = openid
|
||||||
|
user.save(update_fields=['OpenID'])
|
||||||
|
return row
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
return UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
|
return UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
|
||||||
|
|
||||||
@@ -200,32 +221,85 @@ def _looks_like_wx_openid(value):
|
|||||||
return len(v) >= 20 and v.startswith('o')
|
return len(v) >= 20 and v.startswith('o')
|
||||||
|
|
||||||
|
|
||||||
def get_wx_openid_for_user(user, club_id=None):
|
def resolve_club_payment_openid(user, club_id=None):
|
||||||
"""
|
"""
|
||||||
按俱乐部取微信支付 JSAPI 用 openid。
|
支付专用:按俱乐部取 openid,与 club 表 miniapp AppID 对应。
|
||||||
多俱乐部用户 openid 在 user_wx_openid;xq 老用户可回落 User.OpenID;
|
1) user_wx_openid(yonghuid, club_id)
|
||||||
星之界等俱乐部用户常 UserName=openid 且 OpenID 为空,再回落 UserName。
|
2) 无本 club 绑定但有其他 club → 拒绝(须在本小程序重新登录)
|
||||||
|
3) 无任何绑定时 lazy backfill:xq→User.OpenID;xzj 等→UserName(openid)
|
||||||
"""
|
"""
|
||||||
if not user:
|
if not user:
|
||||||
return ''
|
return '', 'none'
|
||||||
cid = (club_id or '').strip() or get_user_club_id(user)
|
cid = (club_id or '').strip() or get_user_club_id(user)
|
||||||
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
|
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
|
||||||
if uid and cid:
|
if not uid or not cid:
|
||||||
binding = UserWxOpenid.query.filter(yonghuid=uid, club_id=cid).first()
|
return '', 'none'
|
||||||
if binding and binding.openid:
|
|
||||||
return binding.openid
|
binding = (
|
||||||
legacy = getattr(user, 'OpenID', None) or getattr(user, 'openid', None)
|
UserWxOpenid.query.filter(yonghuid=uid, club_id=cid)
|
||||||
if legacy:
|
.order_by('-id')
|
||||||
return legacy.strip()
|
.first()
|
||||||
|
)
|
||||||
|
if binding and binding.openid:
|
||||||
|
return binding.openid.strip(), f'user_wx_openid#{binding.id}'
|
||||||
|
|
||||||
|
other_clubs = list(
|
||||||
|
UserWxOpenid.query.filter(yonghuid=uid)
|
||||||
|
.exclude(club_id=cid)
|
||||||
|
.values_list('club_id', flat=True)[:5]
|
||||||
|
)
|
||||||
|
if other_clubs:
|
||||||
|
logger.warning(
|
||||||
|
'[PAY_OPENID] uid=%s 无 club=%s 绑定,已有其他俱乐部 %s;须在本小程序重新登录',
|
||||||
|
uid, cid, other_clubs,
|
||||||
|
)
|
||||||
|
return '', 'other_club_only'
|
||||||
|
|
||||||
username = (getattr(user, 'UserName', None) or '').strip()
|
username = (getattr(user, 'UserName', None) or '').strip()
|
||||||
if _looks_like_wx_openid(username):
|
legacy_oid = (getattr(user, 'OpenID', None) or getattr(user, 'openid', None) or '').strip()
|
||||||
return username
|
candidate = ''
|
||||||
return ''
|
source = ''
|
||||||
|
if cid == CLUB_ID_DEFAULT:
|
||||||
|
if legacy_oid and _looks_like_wx_openid(legacy_oid):
|
||||||
|
candidate, source = legacy_oid, 'legacy_OpenID'
|
||||||
|
elif _looks_like_wx_openid(username):
|
||||||
|
candidate, source = username, 'legacy_UserName'
|
||||||
|
|
||||||
|
if candidate:
|
||||||
|
logger.info('[PAY_OPENID] uid=%s club=%s lazy backfill from %s', uid, cid, source)
|
||||||
|
ensure_wx_openid_binding(cid, candidate, user)
|
||||||
|
return candidate, source
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
'[PAY_OPENID] uid=%s club=%s 无绑定 legacy_UserName=%s… legacy_OpenID=%s',
|
||||||
|
uid, cid, username[:10] if username else '', bool(legacy_oid),
|
||||||
|
)
|
||||||
|
return '', 'missing'
|
||||||
|
|
||||||
|
|
||||||
|
def get_wx_openid_for_user(user, club_id=None):
|
||||||
|
openid, _ = resolve_club_payment_openid(user, club_id)
|
||||||
|
return openid
|
||||||
|
|
||||||
|
|
||||||
|
def get_wx_openid_for_user_with_source(user, club_id=None):
|
||||||
|
return resolve_club_payment_openid(user, club_id)
|
||||||
|
|
||||||
|
|
||||||
def get_payment_openid(request, user=None, club_id=None):
|
def get_payment_openid(request, user=None, club_id=None):
|
||||||
"""下单/充值:按请求俱乐部解析 openid。"""
|
"""下单/充值:按请求俱乐部解析 openid。"""
|
||||||
from jituan.services.club_write import resolve_club_id_for_write
|
from jituan.services.club_write import resolve_club_id_for_write
|
||||||
user = user or getattr(request, 'user', None)
|
user = user or getattr(request, 'user', None)
|
||||||
cid = club_id or resolve_club_id_for_write(request, user)
|
cid = (club_id or '').strip() or resolve_club_id_for_write(request, user)
|
||||||
return get_wx_openid_for_user(user, cid), cid
|
openid, source = get_wx_openid_for_user_with_source(user, cid)
|
||||||
|
if openid:
|
||||||
|
logger.info('[PAY_OPENID] uid=%s club=%s openid=%s… source=%s',
|
||||||
|
getattr(user, 'UserUID', ''), cid,
|
||||||
|
openid[:6] + '...' if len(openid) > 10 else openid, source)
|
||||||
|
elif user:
|
||||||
|
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
|
||||||
|
logger.warning(
|
||||||
|
'[PAY_OPENID] 无法解析支付 openid uid=%s club=%s source=%s user_club=%s',
|
||||||
|
uid, cid, source, get_user_club_id(user),
|
||||||
|
)
|
||||||
|
return openid, cid
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from jituan.constants import CLUB_ID_DEFAULT
|
|||||||
from jituan.services.club_penalty import resolve_gsfenhong_club_id
|
from jituan.services.club_penalty import resolve_gsfenhong_club_id
|
||||||
from orders.models import CommissionRate
|
from orders.models import CommissionRate
|
||||||
from products.models import Czjilu, Gsfenhong
|
from products.models import Czjilu, Gsfenhong
|
||||||
|
from products.czjilu_types import is_czjilu_fulfillable_status
|
||||||
from users.models import UserDashou, UserGuanshi
|
from users.models import UserDashou, UserGuanshi
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -26,15 +27,73 @@ class CzjiluFulfillError(Exception):
|
|||||||
self.code = code
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def fulfill_zhuanpan(dingdan_id, paid_amount_yuan=None):
|
||||||
|
"""转盘购票履约:Czjilu 标已付 + 抽奖机会改为待抽。绝不创建抢单池 Order。"""
|
||||||
|
from products.czjilu_types import CZJILU_LEIXING_ZHUANPAN, CZJILU_PAID_STATUS
|
||||||
|
from products.models import ZhuanpanJihui
|
||||||
|
|
||||||
|
try:
|
||||||
|
order = Czjilu.query.select_for_update().get(dingdan_id=dingdan_id)
|
||||||
|
except Czjilu.DoesNotExist:
|
||||||
|
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||||
|
|
||||||
|
if order.leixing != CZJILU_LEIXING_ZHUANPAN:
|
||||||
|
raise CzjiluFulfillError('订单类型不是转盘购票', 'type_error')
|
||||||
|
|
||||||
|
if order.zhuangtai == CZJILU_PAID_STATUS:
|
||||||
|
return {'already_done': True, 'dingdan_id': dingdan_id, 'zhuangtai': CZJILU_PAID_STATUS}
|
||||||
|
|
||||||
|
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||||
|
return {'already_done': True, 'dingdan_id': dingdan_id, 'zhuangtai': order.zhuangtai}
|
||||||
|
|
||||||
|
amount = Decimal(str(order.jine))
|
||||||
|
if paid_amount_yuan is not None:
|
||||||
|
paid = Decimal(str(paid_amount_yuan))
|
||||||
|
if abs(paid - amount) > Decimal('0.01'):
|
||||||
|
raise CzjiluFulfillError(f'支付金额{paid}与订单{amount}不一致', 'amount_mismatch')
|
||||||
|
|
||||||
|
jihui = ZhuanpanJihui.query.select_for_update().filter(
|
||||||
|
czjilu_dingdan_id=dingdan_id
|
||||||
|
).first()
|
||||||
|
if not jihui:
|
||||||
|
raise CzjiluFulfillError('转盘机会单不存在', 'not_found')
|
||||||
|
|
||||||
|
order.zhuangtai = CZJILU_PAID_STATUS
|
||||||
|
order.save(update_fields=['zhuangtai'])
|
||||||
|
|
||||||
|
if int(jihui.zhuangtai) == 9:
|
||||||
|
jihui.zhuangtai = 1 # 已付待抽
|
||||||
|
jihui.save(update_fields=['zhuangtai', 'UpdateTime'])
|
||||||
|
|
||||||
|
try:
|
||||||
|
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||||||
|
repair_wechat_income_if_missing(
|
||||||
|
amount,
|
||||||
|
getattr(order, 'club_id', None) or CLUB_ID_DEFAULT,
|
||||||
|
biz_ref=f'cz:{dingdan_id}',
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('转盘购票补记收支失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
|
||||||
|
|
||||||
|
logger.info('转盘购票履约完成 dingdan=%s jihui=%s', dingdan_id, jihui.id)
|
||||||
|
return {
|
||||||
|
'already_done': False,
|
||||||
|
'dingdan_id': dingdan_id,
|
||||||
|
'zhuangtai': CZJILU_PAID_STATUS,
|
||||||
|
'jihui_id': jihui.id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def fulfill_yajin_recharge(dingdan_id, paid_amount_yuan=None):
|
def fulfill_yajin_recharge(dingdan_id, paid_amount_yuan=None):
|
||||||
"""押金充值幂等履约:仅 zhuangtai==9 时入账。"""
|
"""押金充值幂等履约:仅待履约状态(9/用户关单2)时入账;已付(3)直接跳过防多加。"""
|
||||||
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
|
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
|
||||||
if not order:
|
if not order:
|
||||||
raise CzjiluFulfillError('订单不存在', 'not_found')
|
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||||
if order.leixing != 2:
|
if order.leixing != 2:
|
||||||
raise CzjiluFulfillError('订单类型不是押金', 'type_error')
|
raise CzjiluFulfillError('订单类型不是押金', 'type_error')
|
||||||
if order.zhuangtai != 9:
|
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||||
try:
|
try:
|
||||||
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||||||
repair_wechat_income_if_missing(
|
repair_wechat_income_if_missing(
|
||||||
@@ -118,13 +177,13 @@ def _apply_yajin_fenhong(order, dashou, amount):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def fulfill_jifen_recharge(dingdan_id, paid_amount_yuan=None):
|
def fulfill_jifen_recharge(dingdan_id, paid_amount_yuan=None):
|
||||||
"""积分补充幂等履约:仅 zhuangtai==9 时入账。"""
|
"""积分补充幂等履约:仅待履约状态(9/2)时入账。"""
|
||||||
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
|
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
|
||||||
if not order:
|
if not order:
|
||||||
raise CzjiluFulfillError('订单不存在', 'not_found')
|
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||||
if order.leixing != 3:
|
if order.leixing != 3:
|
||||||
raise CzjiluFulfillError('订单类型不是积分', 'type_error')
|
raise CzjiluFulfillError('订单类型不是积分', 'type_error')
|
||||||
if order.zhuangtai != 9:
|
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||||
try:
|
try:
|
||||||
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||||||
repair_wechat_income_if_missing(
|
repair_wechat_income_if_missing(
|
||||||
@@ -179,7 +238,7 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
|
|||||||
raise CzjiluFulfillError('订单不存在', 'not_found')
|
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||||
if order.leixing != 6:
|
if order.leixing != 6:
|
||||||
raise CzjiluFulfillError('订单类型不是罚款', 'type_error')
|
raise CzjiluFulfillError('订单类型不是罚款', 'type_error')
|
||||||
if order.zhuangtai != 9:
|
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||||
try:
|
try:
|
||||||
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||||||
repair_wechat_income_if_missing(
|
repair_wechat_income_if_missing(
|
||||||
@@ -205,17 +264,33 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
|
|||||||
fadan = None
|
fadan = None
|
||||||
m = re.search(r'#fadan:(\d+)', order.shuoming or '')
|
m = re.search(r'#fadan:(\d+)', order.shuoming or '')
|
||||||
if m:
|
if m:
|
||||||
|
fadan_id_str = m.group(1)
|
||||||
fadan = Penalty.query.filter(
|
fadan = Penalty.query.filter(
|
||||||
id=int(m.group(1)),
|
id=int(fadan_id_str),
|
||||||
PenalizedUserID=order.yonghuid,
|
PenalizedUserID=order.yonghuid,
|
||||||
Status__in=[1, 3],
|
Status__in=[1, 3],
|
||||||
).first()
|
).first()
|
||||||
|
if not fadan:
|
||||||
|
logger.warning(
|
||||||
|
'罚款订单 %s shuoming 标记罚单 %s 未找到(被处罚者=%s, 状态需为1/3)',
|
||||||
|
dingdan_id, fadan_id_str, order.yonghuid,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
'罚款订单 %s shuoming 无 #fadan:xxx 标记,回退金额匹配: %s',
|
||||||
|
dingdan_id, order.shuoming,
|
||||||
|
)
|
||||||
if not fadan:
|
if not fadan:
|
||||||
fadan = Penalty.query.filter(
|
fadan = Penalty.query.filter(
|
||||||
PenalizedUserID=order.yonghuid,
|
PenalizedUserID=order.yonghuid,
|
||||||
FineAmount=order.jine,
|
FineAmount=order.jine,
|
||||||
Status__in=[1, 3],
|
Status__in=[1, 3],
|
||||||
).order_by('-CreateTime').first()
|
).order_by('-CreateTime').first()
|
||||||
|
if fadan:
|
||||||
|
logger.warning(
|
||||||
|
'罚款订单 %s 回退金额匹配到罚单 %s(金额=%s)',
|
||||||
|
dingdan_id, fadan.id, order.jine,
|
||||||
|
)
|
||||||
if fadan:
|
if fadan:
|
||||||
fadan.Status = 2
|
fadan.Status = 2
|
||||||
fadan.save(update_fields=['Status'])
|
fadan.save(update_fields=['Status'])
|
||||||
@@ -224,6 +299,11 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
|
|||||||
logger.error('罚单 %s 分红处理失败: %s', fadan.id, msg)
|
logger.error('罚单 %s 分红处理失败: %s', fadan.id, msg)
|
||||||
else:
|
else:
|
||||||
logger.info('罚单 %s 分红处理完成: %s', fadan.id, msg)
|
logger.info('罚单 %s 分红处理完成: %s', fadan.id, msg)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
'罚款订单 %s 未找到对应罚单,罚单状态未更新(被处罚者=%s, 金额=%s)',
|
||||||
|
dingdan_id, order.yonghuid, order.jine,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error('罚款罚单状态更新失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
|
logger.error('罚款罚单状态更新失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
|
||||||
|
|
||||||
@@ -239,7 +319,7 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
|
def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
|
||||||
"""考核支付幂等履约:仅 zhuangtai==9 时入账。"""
|
"""考核支付幂等履约:仅待履约状态(9/2)时入账。"""
|
||||||
from rank.models import KaohePayTemp
|
from rank.models import KaohePayTemp
|
||||||
from rank.utils import create_shenhe_jilu_from_temp
|
from rank.utils import create_shenhe_jilu_from_temp
|
||||||
from users.business_models import User
|
from users.business_models import User
|
||||||
@@ -249,7 +329,7 @@ def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
|
|||||||
raise CzjiluFulfillError('订单不存在', 'not_found')
|
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||||
if order.leixing != 5:
|
if order.leixing != 5:
|
||||||
raise CzjiluFulfillError('订单类型不是考核', 'type_error')
|
raise CzjiluFulfillError('订单类型不是考核', 'type_error')
|
||||||
if order.zhuangtai != 9:
|
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||||
try:
|
try:
|
||||||
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||||||
repair_wechat_income_if_missing(
|
repair_wechat_income_if_missing(
|
||||||
@@ -302,7 +382,7 @@ def fulfill_kaohe(dingdan_id, paid_amount_yuan=None):
|
|||||||
|
|
||||||
@transaction.atomic
|
@transaction.atomic
|
||||||
def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
|
def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
|
||||||
"""商家充值幂等履约:仅 zhuangtai==9 时入账。"""
|
"""商家充值幂等履约:仅待履约状态(9/2)时入账;已付(3)跳过防多加。"""
|
||||||
from users.models import UserShangjia
|
from users.models import UserShangjia
|
||||||
|
|
||||||
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
|
order = Czjilu.query.filter(dingdan_id=dingdan_id).select_for_update().first()
|
||||||
@@ -310,7 +390,7 @@ def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
|
|||||||
raise CzjiluFulfillError('订单不存在', 'not_found')
|
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||||
if order.leixing != 4:
|
if order.leixing != 4:
|
||||||
raise CzjiluFulfillError('订单类型不是商家充值', 'type_error')
|
raise CzjiluFulfillError('订单类型不是商家充值', 'type_error')
|
||||||
if order.zhuangtai != 9:
|
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||||
try:
|
try:
|
||||||
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
from jituan.services.szjilu_accounting import repair_wechat_income_if_missing
|
||||||
repair_wechat_income_if_missing(
|
repair_wechat_income_if_missing(
|
||||||
@@ -350,9 +430,7 @@ def fulfill_shangjia(dingdan_id, paid_amount_yuan=None):
|
|||||||
|
|
||||||
|
|
||||||
def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
|
def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
|
||||||
"""轮询确认:支付宝订单直接看本地状态,微信订单查微信 SUCCESS 后幂等履约。"""
|
"""轮询确认:付呗/微信/支付宝官方查单成功后幂等履约(防多加)。"""
|
||||||
from django.core.cache import cache
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
order = Czjilu.query.get(dingdan_id=dingdan_id, yonghuid=yonghuid)
|
order = Czjilu.query.get(dingdan_id=dingdan_id, yonghuid=yonghuid)
|
||||||
except Czjilu.DoesNotExist:
|
except Czjilu.DoesNotExist:
|
||||||
@@ -373,21 +451,22 @@ def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
|
|||||||
logger.error('充值单补记收支失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
|
logger.error('充值单补记收支失败 dingdan=%s: %s', dingdan_id, exc, exc_info=True)
|
||||||
return {'already_done': True, 'zhuangtai': 3}
|
return {'already_done': True, 'zhuangtai': 3}
|
||||||
|
|
||||||
if order.zhuangtai != 9:
|
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||||
raise CzjiluFulfillError(f'订单状态异常: {order.zhuangtai}', 'status_error')
|
raise CzjiluFulfillError(f'订单状态异常: {order.zhuangtai}', 'status_error')
|
||||||
|
|
||||||
# 支付宝订单:异步回调会更新状态,轮询时只看本地状态,未支付则提示稍后重试
|
|
||||||
is_alipay = cache.get(f'alipay_pay_url_{dingdan_id}') is not None
|
|
||||||
if is_alipay:
|
|
||||||
raise CzjiluFulfillError('支付宝支付结果尚未同步,请稍后再试', 'not_paid')
|
|
||||||
|
|
||||||
# 微信订单:主动查单
|
|
||||||
from jituan.services.member_recharge import query_wechat_v2_trade_state
|
|
||||||
trade_state, _ = query_wechat_v2_trade_state(dingdan_id, getattr(order, 'club_id', None))
|
|
||||||
if trade_state != 'SUCCESS':
|
|
||||||
raise CzjiluFulfillError('微信侧尚未支付成功,请稍后再试', 'not_paid')
|
|
||||||
|
|
||||||
paid = Decimal(str(order.jine))
|
paid = Decimal(str(order.jine))
|
||||||
|
club_id = getattr(order, 'club_id', None)
|
||||||
|
|
||||||
|
from jituan.services.pay_confirm import confirm_remote_payment
|
||||||
|
remote = confirm_remote_payment(dingdan_id, club_id, prefer_amount=paid)
|
||||||
|
if not remote.get('ok'):
|
||||||
|
if remote.get('pending'):
|
||||||
|
raise CzjiluFulfillError('支付结果尚未同步,请稍后再试', 'not_paid')
|
||||||
|
raise CzjiluFulfillError(remote.get('msg') or '支付未成功', 'not_paid')
|
||||||
|
if remote.get('amount') is not None:
|
||||||
|
paid = Decimal(str(remote['amount']))
|
||||||
|
|
||||||
|
# 查单确认支付成功,执行履约(fulfill_* 内行锁+仅 9/2→3,防多加)
|
||||||
if expected_leixing == 1:
|
if expected_leixing == 1:
|
||||||
from jituan.services.member_recharge import fulfill_member_recharge
|
from jituan.services.member_recharge import fulfill_member_recharge
|
||||||
return fulfill_member_recharge(dingdan_id, paid_amount_yuan=paid, source='confirm')
|
return fulfill_member_recharge(dingdan_id, paid_amount_yuan=paid, source='confirm')
|
||||||
@@ -401,4 +480,15 @@ def confirm_czjilu_paid(dingdan_id, yonghuid, expected_leixing):
|
|||||||
return fulfill_kaohe(dingdan_id, paid_amount_yuan=paid)
|
return fulfill_kaohe(dingdan_id, paid_amount_yuan=paid)
|
||||||
if expected_leixing == 6:
|
if expected_leixing == 6:
|
||||||
return fulfill_fakuan(dingdan_id, paid_amount_yuan=paid)
|
return fulfill_fakuan(dingdan_id, paid_amount_yuan=paid)
|
||||||
|
if expected_leixing == 7:
|
||||||
|
return fulfill_zhuanpan(dingdan_id, paid_amount_yuan=paid)
|
||||||
raise CzjiluFulfillError('不支持的充值类型', 'type_error')
|
raise CzjiluFulfillError('不支持的充值类型', 'type_error')
|
||||||
|
|
||||||
|
|
||||||
|
def repair_czjilu_payment(dingdan_id):
|
||||||
|
"""客服补单:官方查单确认已付后,走同一套幂等履约(绝不多加)。"""
|
||||||
|
try:
|
||||||
|
order = Czjilu.query.get(dingdan_id=dingdan_id)
|
||||||
|
except Czjilu.DoesNotExist:
|
||||||
|
raise CzjiluFulfillError('订单不存在', 'not_found')
|
||||||
|
return confirm_czjilu_paid(dingdan_id, order.yonghuid, expected_leixing=order.leixing)
|
||||||
|
|||||||
@@ -11,26 +11,44 @@ from rest_framework.response import Response
|
|||||||
# 定稿 B 方案 page_key
|
# 定稿 B 方案 page_key
|
||||||
LUNBO_PAGE_ORDER_POOL = 'order_pool'
|
LUNBO_PAGE_ORDER_POOL = 'order_pool'
|
||||||
LUNBO_PAGE_ACCEPT_ORDER = 'accept_order'
|
LUNBO_PAGE_ACCEPT_ORDER = 'accept_order'
|
||||||
|
LUNBO_PAGE_ACCEPT_BANNER2 = 'accept_order_banner2' # 点单页第二横幅轮播(如 UFO banner2)
|
||||||
LUNBO_PAGE_MERCHANT_HOME = 'merchant_home'
|
LUNBO_PAGE_MERCHANT_HOME = 'merchant_home'
|
||||||
LUNBO_PAGE_DASHOU_CENTER = 'dashou_center'
|
LUNBO_PAGE_DASHOU_CENTER = 'dashou_center'
|
||||||
|
LUNBO_PAGE_BOSS_CENTER = 'boss_center' # 点单端「我的」顶区背景
|
||||||
|
|
||||||
|
LUNBO_CENTER_BG_KEYS = {LUNBO_PAGE_DASHOU_CENTER, LUNBO_PAGE_BOSS_CENTER}
|
||||||
|
LUNBO_CAROUSEL_KEYS = {
|
||||||
|
LUNBO_PAGE_ORDER_POOL,
|
||||||
|
LUNBO_PAGE_ACCEPT_ORDER,
|
||||||
|
LUNBO_PAGE_ACCEPT_BANNER2,
|
||||||
|
LUNBO_PAGE_MERCHANT_HOME,
|
||||||
|
}
|
||||||
|
|
||||||
LUNBO_PAGE_OPTIONS = [
|
LUNBO_PAGE_OPTIONS = [
|
||||||
(LUNBO_PAGE_ORDER_POOL, '抢单池'),
|
(LUNBO_PAGE_ORDER_POOL, '抢单池'),
|
||||||
(LUNBO_PAGE_ACCEPT_ORDER, '点单页'),
|
(LUNBO_PAGE_ACCEPT_ORDER, '点单页主轮播'),
|
||||||
|
(LUNBO_PAGE_ACCEPT_BANNER2, '点单页第二横幅(可多图)'),
|
||||||
(LUNBO_PAGE_MERCHANT_HOME, '商家首页'),
|
(LUNBO_PAGE_MERCHANT_HOME, '商家首页'),
|
||||||
(LUNBO_PAGE_DASHOU_CENTER, '打手个人中心背景'),
|
(LUNBO_PAGE_DASHOU_CENTER, '打手个人中心背景'),
|
||||||
|
(LUNBO_PAGE_BOSS_CENTER, '点单个人中心背景(可多图)'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def normalize_page_key(page_key, image_type=1):
|
def normalize_page_key(page_key, image_type=1):
|
||||||
key = (page_key or '').strip()
|
key = (page_key or '').strip()
|
||||||
|
if key in LUNBO_CENTER_BG_KEYS or key in LUNBO_CAROUSEL_KEYS:
|
||||||
|
return key
|
||||||
if image_type == 2:
|
if image_type == 2:
|
||||||
return LUNBO_PAGE_DASHOU_CENTER
|
return LUNBO_PAGE_DASHOU_CENTER
|
||||||
if key in {LUNBO_PAGE_ORDER_POOL, LUNBO_PAGE_ACCEPT_ORDER, LUNBO_PAGE_MERCHANT_HOME}:
|
|
||||||
return key
|
|
||||||
return LUNBO_PAGE_ORDER_POOL
|
return LUNBO_PAGE_ORDER_POOL
|
||||||
|
|
||||||
|
|
||||||
|
def lunbo_image_type_for_page(page_key):
|
||||||
|
"""个人中心背景页用 ImageType=2,其余轮播用 1。"""
|
||||||
|
pk = normalize_page_key(page_key, image_type=1)
|
||||||
|
return 2 if pk in LUNBO_CENTER_BG_KEYS else 1
|
||||||
|
|
||||||
|
|
||||||
def forbid_display_write_in_all_scope(request):
|
def forbid_display_write_in_all_scope(request):
|
||||||
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||||
return Response({'code': 403, 'msg': '请在子公司视图下修改展示配置'})
|
return Response({'code': 403, 'msg': '请在子公司视图下修改展示配置'})
|
||||||
@@ -73,6 +91,9 @@ def get_lunbo_urls(request, page_key=None, image_type=1):
|
|||||||
from config.models import Lunbo
|
from config.models import Lunbo
|
||||||
club_id = _display_club_id(request)
|
club_id = _display_club_id(request)
|
||||||
pk = normalize_page_key(page_key, image_type=image_type)
|
pk = normalize_page_key(page_key, image_type=image_type)
|
||||||
|
# 个人中心背景页一律 ImageType=2,避免误传 image_type=1 读不到
|
||||||
|
if pk in LUNBO_CENTER_BG_KEYS:
|
||||||
|
image_type = 2
|
||||||
qs = Lunbo.query.filter(club_id=club_id, ImageType=image_type, page_key=pk)
|
qs = Lunbo.query.filter(club_id=club_id, ImageType=image_type, page_key=pk)
|
||||||
return [row.ImageURL for row in qs.order_by('id') if row.ImageURL]
|
return [row.ImageURL for row in qs.order_by('id') if row.ImageURL]
|
||||||
|
|
||||||
@@ -108,12 +129,20 @@ def list_tupianpeizhi_by_type(club_id, image_type):
|
|||||||
|
|
||||||
|
|
||||||
def upsert_tupianpeizhi_single(club_id, image_type, image_url):
|
def upsert_tupianpeizhi_single(club_id, image_type, image_url):
|
||||||
"""ImageType 1/2 每俱乐部仅一条。"""
|
"""ImageType 1/2 每俱乐部仅一条;换图时删除旧 OSS 对象。"""
|
||||||
from config.models import Tupianpeizhi
|
from config.models import Tupianpeizhi
|
||||||
|
from utils.oss_utils import delete_from_oss
|
||||||
|
|
||||||
row = Tupianpeizhi.query.filter(club_id=club_id, ImageType=image_type).first()
|
row = Tupianpeizhi.query.filter(club_id=club_id, ImageType=image_type).first()
|
||||||
if row:
|
if row:
|
||||||
|
old = (row.ImageURL or '').strip()
|
||||||
row.ImageURL = image_url
|
row.ImageURL = image_url
|
||||||
row.save(update_fields=['ImageURL', 'UpdateTime'])
|
row.save(update_fields=['ImageURL', 'UpdateTime'])
|
||||||
|
if old and old != image_url:
|
||||||
|
try:
|
||||||
|
delete_from_oss(old)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
Tupianpeizhi.query.create(
|
Tupianpeizhi.query.create(
|
||||||
club_id=club_id, ImageType=image_type, ImageURL=image_url, AccessCount=0,
|
club_id=club_id, ImageType=image_type, ImageURL=image_url, AccessCount=0,
|
||||||
|
|||||||
227
jituan/services/fubei_client.py
Normal file
227
jituan/services/fubei_client.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""付呗开放平台客户端:签名、小程序下单、退款、回调验签。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
METHOD_MINA = 'openapi.payment.order.mina'
|
||||||
|
METHOD_REFUND = 'openapi.payment.order.refund'
|
||||||
|
METHOD_QUERY = 'openapi.payment.order.query'
|
||||||
|
|
||||||
|
|
||||||
|
def _yuan_str(amount) -> str:
|
||||||
|
d = Decimal(str(amount)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
|
||||||
|
return format(d, 'f')
|
||||||
|
|
||||||
|
|
||||||
|
def build_sign(params: Dict[str, Any], app_secret: str) -> str:
|
||||||
|
"""付呗签名:参数 ASCII 排序后 key=value&... 再无缝拼接 secret,MD5 大写。"""
|
||||||
|
items = []
|
||||||
|
for k in sorted(params.keys()):
|
||||||
|
if k == 'sign':
|
||||||
|
continue
|
||||||
|
v = params[k]
|
||||||
|
if v is None or v == '':
|
||||||
|
continue
|
||||||
|
items.append(f'{k}={v}')
|
||||||
|
raw = '&'.join(items) + (app_secret or '')
|
||||||
|
return hashlib.md5(raw.encode('utf-8')).hexdigest().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_callback_sign(params: Dict[str, Any], app_secret: str) -> bool:
|
||||||
|
got = (params.get('sign') or '').strip().upper()
|
||||||
|
if not got:
|
||||||
|
return False
|
||||||
|
expect = build_sign(params, app_secret)
|
||||||
|
return got == expect
|
||||||
|
|
||||||
|
|
||||||
|
def _nonce(n: int = 24) -> str:
|
||||||
|
return ''.join(random.choices(string.ascii_letters + string.digits, k=n))
|
||||||
|
|
||||||
|
|
||||||
|
def call_fubei_api(
|
||||||
|
*,
|
||||||
|
gateway_url: str,
|
||||||
|
app_id: str,
|
||||||
|
app_secret: str,
|
||||||
|
method: str,
|
||||||
|
biz: Dict[str, Any],
|
||||||
|
timeout: int = 15,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""调用付呗 gateway,成功返回 data 字典;失败抛 Exception。"""
|
||||||
|
biz_content = json.dumps(biz, ensure_ascii=False, separators=(',', ':'))
|
||||||
|
payload = {
|
||||||
|
'app_id': app_id,
|
||||||
|
'method': method,
|
||||||
|
'format': 'json',
|
||||||
|
'sign_method': 'md5',
|
||||||
|
'nonce': _nonce(),
|
||||||
|
'version': '1.0',
|
||||||
|
'biz_content': biz_content,
|
||||||
|
}
|
||||||
|
payload['sign'] = build_sign(payload, app_secret)
|
||||||
|
|
||||||
|
url = (gateway_url or '').strip() or 'https://shq-api.51fubei.com/gateway'
|
||||||
|
logger.info('[FUBEI] method=%s app_id=%s biz_keys=%s', method, app_id, list(biz.keys()))
|
||||||
|
resp = requests.post(url, json=payload, timeout=timeout, headers={'Content-Type': 'application/json; charset=utf-8'})
|
||||||
|
try:
|
||||||
|
body = resp.json()
|
||||||
|
except Exception:
|
||||||
|
logger.error('[FUBEI] 非JSON响应 status=%s body=%s', resp.status_code, resp.text[:500])
|
||||||
|
raise Exception(f'付呗接口异常: HTTP {resp.status_code}')
|
||||||
|
|
||||||
|
result_code = body.get('result_code')
|
||||||
|
if str(result_code) not in ('200', '200.0'):
|
||||||
|
msg = body.get('result_message') or body.get('sub_code') or '付呗下单失败'
|
||||||
|
logger.warning('[FUBEI] 业务失败 method=%s code=%s msg=%s body=%s', method, result_code, msg, body)
|
||||||
|
raise Exception(str(msg))
|
||||||
|
|
||||||
|
data = body.get('data')
|
||||||
|
if isinstance(data, str):
|
||||||
|
try:
|
||||||
|
data = json.loads(data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise Exception('付呗返回 data 格式异常')
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def mina_prepay(
|
||||||
|
*,
|
||||||
|
gateway_url: str,
|
||||||
|
app_id: str,
|
||||||
|
app_secret: str,
|
||||||
|
merchant_order_sn: str,
|
||||||
|
sub_openid: str,
|
||||||
|
total_fee,
|
||||||
|
store_id: int,
|
||||||
|
call_back_url: str,
|
||||||
|
body: str = '',
|
||||||
|
attach: str = '',
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""小程序支付下单,返回含 sign_params / order_sn 等。"""
|
||||||
|
biz = {
|
||||||
|
'merchant_order_sn': merchant_order_sn,
|
||||||
|
'sub_openid': sub_openid,
|
||||||
|
'total_fee': float(_yuan_str(total_fee)),
|
||||||
|
'store_id': int(store_id),
|
||||||
|
}
|
||||||
|
if call_back_url:
|
||||||
|
biz['call_back_url'] = call_back_url
|
||||||
|
if body:
|
||||||
|
biz['body'] = (body or '')[:128]
|
||||||
|
if attach:
|
||||||
|
biz['attach'] = (attach or '')[:127]
|
||||||
|
|
||||||
|
return call_fubei_api(
|
||||||
|
gateway_url=gateway_url,
|
||||||
|
app_id=app_id,
|
||||||
|
app_secret=app_secret,
|
||||||
|
method=METHOD_MINA,
|
||||||
|
biz=biz,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def refund_order(
|
||||||
|
*,
|
||||||
|
gateway_url: str,
|
||||||
|
app_id: str,
|
||||||
|
app_secret: str,
|
||||||
|
merchant_order_sn: str,
|
||||||
|
merchant_refund_sn: str,
|
||||||
|
refund_money=None,
|
||||||
|
order_sn: str = '',
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
biz: Dict[str, Any] = {
|
||||||
|
'merchant_refund_sn': merchant_refund_sn,
|
||||||
|
}
|
||||||
|
if merchant_order_sn:
|
||||||
|
biz['merchant_order_sn'] = merchant_order_sn
|
||||||
|
if order_sn:
|
||||||
|
biz['order_sn'] = order_sn
|
||||||
|
if refund_money is not None:
|
||||||
|
biz['refund_money'] = float(_yuan_str(refund_money))
|
||||||
|
|
||||||
|
return call_fubei_api(
|
||||||
|
gateway_url=gateway_url,
|
||||||
|
app_id=app_id,
|
||||||
|
app_secret=app_secret,
|
||||||
|
method=METHOD_REFUND,
|
||||||
|
biz=biz,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def query_order(
|
||||||
|
*,
|
||||||
|
gateway_url: str,
|
||||||
|
app_id: str,
|
||||||
|
app_secret: str,
|
||||||
|
merchant_order_sn: str = '',
|
||||||
|
order_sn: str = '',
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""订单查询。trade_state=SUCCESS 表示已支付。"""
|
||||||
|
biz: Dict[str, Any] = {}
|
||||||
|
if merchant_order_sn:
|
||||||
|
biz['merchant_order_sn'] = merchant_order_sn
|
||||||
|
if order_sn:
|
||||||
|
biz['order_sn'] = order_sn
|
||||||
|
if not biz:
|
||||||
|
raise Exception('付呗查单缺少订单号')
|
||||||
|
return call_fubei_api(
|
||||||
|
gateway_url=gateway_url,
|
||||||
|
app_id=app_id,
|
||||||
|
app_secret=app_secret,
|
||||||
|
method=METHOD_QUERY,
|
||||||
|
biz=biz,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_callback_form(post_dict: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||||
|
"""解析付呗 form 回调,返回 (顶层参数含 sign, data 业务字典)。"""
|
||||||
|
params = {k: (v if not isinstance(v, list) else (v[0] if v else '')) for k, v in post_dict.items()}
|
||||||
|
# 兼容 QueryDict.dict()
|
||||||
|
data_raw = params.get('data') or ''
|
||||||
|
data: Dict[str, Any] = {}
|
||||||
|
if isinstance(data_raw, dict):
|
||||||
|
data = data_raw
|
||||||
|
elif isinstance(data_raw, str) and data_raw.strip():
|
||||||
|
try:
|
||||||
|
data = json.loads(data_raw)
|
||||||
|
except Exception:
|
||||||
|
# 偶发双重编码
|
||||||
|
try:
|
||||||
|
data = json.loads(data_raw.replace("'", '"'))
|
||||||
|
except Exception:
|
||||||
|
logger.error('[FUBEI_NOTIFY] data 解析失败: %s', data_raw[:300])
|
||||||
|
data = {}
|
||||||
|
return params, data
|
||||||
|
|
||||||
|
|
||||||
|
def extract_jsapi_pay_params(sign_params: Dict[str, Any]) -> Dict[str, str]:
|
||||||
|
"""从付呗 sign_params 抽出小程序 wx.requestPayment 所需字段。"""
|
||||||
|
if not sign_params:
|
||||||
|
raise Exception('付呗未返回支付签名包')
|
||||||
|
# 部分字段可能混有 store_id 等,只取调起支付字段
|
||||||
|
out = {
|
||||||
|
'appId': str(sign_params.get('appId') or sign_params.get('appid') or ''),
|
||||||
|
'timeStamp': str(sign_params.get('timeStamp') or sign_params.get('timestamp') or ''),
|
||||||
|
'nonceStr': str(sign_params.get('nonceStr') or sign_params.get('noncestr') or ''),
|
||||||
|
'package': str(sign_params.get('package') or ''),
|
||||||
|
'signType': str(sign_params.get('signType') or sign_params.get('sign_type') or 'MD5'),
|
||||||
|
'paySign': str(sign_params.get('paySign') or sign_params.get('pay_sign') or ''),
|
||||||
|
}
|
||||||
|
if not out['timeStamp'] or not out['package'] or not out['paySign']:
|
||||||
|
raise Exception('付呗签名包字段不完整')
|
||||||
|
return out
|
||||||
170
jituan/services/fubei_notify.py
Normal file
170
jituan/services/fubei_notify.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
"""付呗支付异步回调:验签后按订单/充值单履约(前端无感知)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from django.http import HttpResponse
|
||||||
|
from django.views import View
|
||||||
|
|
||||||
|
from jituan.constants import FUBEI_BIZ_CZJILU, FUBEI_BIZ_ORDER
|
||||||
|
from jituan.services.club_payment_channel import get_fubei_config
|
||||||
|
from jituan.services import fubei_client
|
||||||
|
from jituan.services.mini_pay_router import get_pay_ledger
|
||||||
|
from jituan.services.wechat_pay import club_id_from_czjilu, club_id_from_order
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FubeiPayNotifyView(View):
|
||||||
|
"""
|
||||||
|
付呗异步通知。
|
||||||
|
GET:回调 URL 有效性验证,必须返回小写 success。
|
||||||
|
POST:支付成功通知。
|
||||||
|
路径:/dingdan/fubei-notify/
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
return HttpResponse('success', content_type='text/plain')
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
raw_preview = ''
|
||||||
|
try:
|
||||||
|
raw_preview = (request.body or b'')[:800].decode('utf-8', errors='replace')
|
||||||
|
except Exception:
|
||||||
|
raw_preview = ''
|
||||||
|
post = request.POST.dict() if request.POST else {}
|
||||||
|
if not post and request.body:
|
||||||
|
# 偶发 JSON
|
||||||
|
try:
|
||||||
|
post = json.loads(request.body.decode('utf-8'))
|
||||||
|
except Exception:
|
||||||
|
post = {}
|
||||||
|
|
||||||
|
logger.info('[FUBEI_NOTIFY] raw=%s post_keys=%s', raw_preview, list(post.keys()))
|
||||||
|
|
||||||
|
params, data = fubei_client.parse_callback_form(post)
|
||||||
|
merchant_order_sn = str(data.get('merchant_order_sn') or '').strip()
|
||||||
|
result_code = str(params.get('result_code') or '')
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'[FUBEI_NOTIFY] result_code=%s merchant_order_sn=%s trade_no=%s',
|
||||||
|
result_code, merchant_order_sn, data.get('trade_no') or data.get('platform_order_no'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not merchant_order_sn:
|
||||||
|
logger.warning('[FUBEI_NOTIFY] 缺少 merchant_order_sn')
|
||||||
|
return HttpResponse('fail', content_type='text/plain')
|
||||||
|
|
||||||
|
club_id = self._resolve_club_id(merchant_order_sn, data)
|
||||||
|
cfg = get_fubei_config(club_id)
|
||||||
|
if not cfg or not cfg.api_key:
|
||||||
|
logger.error('[FUBEI_NOTIFY] 无付呗密钥 club=%s order=%s', club_id, merchant_order_sn)
|
||||||
|
return HttpResponse('fail', content_type='text/plain')
|
||||||
|
|
||||||
|
if not fubei_client.verify_callback_sign(params, cfg.api_key):
|
||||||
|
# 验签失败仍记录,便于排查;同时尝试用 data 字符串原样参与(部分网关会改写)
|
||||||
|
logger.error(
|
||||||
|
'[FUBEI_NOTIFY] 验签失败 order=%s params=%s',
|
||||||
|
merchant_order_sn,
|
||||||
|
{k: params.get(k) for k in ('result_code', 'result_message', 'sign') if k in params},
|
||||||
|
)
|
||||||
|
return HttpResponse('fail', content_type='text/plain')
|
||||||
|
|
||||||
|
if result_code not in ('200', '200.0'):
|
||||||
|
logger.info('[FUBEI_NOTIFY] 非成功 result_code=%s order=%s', result_code, merchant_order_sn)
|
||||||
|
return HttpResponse('success', content_type='text/plain')
|
||||||
|
|
||||||
|
transaction_id = str(
|
||||||
|
data.get('trade_no')
|
||||||
|
or data.get('platform_order_no')
|
||||||
|
or data.get('order_sn')
|
||||||
|
or ''
|
||||||
|
)
|
||||||
|
total_fee = data.get('total_fee') or data.get('cash_fee') or data.get('order_price')
|
||||||
|
attach = str(data.get('attach') or '')
|
||||||
|
biz = self._biz_type(merchant_order_sn, attach)
|
||||||
|
|
||||||
|
if biz == FUBEI_BIZ_ORDER:
|
||||||
|
ok = self._fulfill_order(merchant_order_sn, transaction_id)
|
||||||
|
else:
|
||||||
|
ok = self._fulfill_czjilu(merchant_order_sn, total_fee, transaction_id)
|
||||||
|
|
||||||
|
return HttpResponse('success' if ok else 'fail', content_type='text/plain')
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('[FUBEI_NOTIFY] 异常: %s', exc)
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return HttpResponse('fail', content_type='text/plain')
|
||||||
|
|
||||||
|
def _resolve_club_id(self, merchant_order_sn: str, data: dict) -> str:
|
||||||
|
ledger = get_pay_ledger(merchant_order_sn)
|
||||||
|
if ledger and ledger.club_id:
|
||||||
|
return ledger.club_id
|
||||||
|
# 先试订单,再试充值单
|
||||||
|
try:
|
||||||
|
cid = club_id_from_order(merchant_order_sn)
|
||||||
|
if cid:
|
||||||
|
return cid
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
cid = club_id_from_czjilu(merchant_order_sn)
|
||||||
|
if cid:
|
||||||
|
return cid
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 'xq'
|
||||||
|
|
||||||
|
def _biz_type(self, merchant_order_sn: str, attach: str) -> str:
|
||||||
|
ledger = get_pay_ledger(merchant_order_sn)
|
||||||
|
if ledger and ledger.biz_type:
|
||||||
|
return ledger.biz_type
|
||||||
|
if 'biz=order' in attach:
|
||||||
|
return FUBEI_BIZ_ORDER
|
||||||
|
if 'biz=czjilu' in attach:
|
||||||
|
return FUBEI_BIZ_CZJILU
|
||||||
|
# 启发式:点单 Order 表优先
|
||||||
|
try:
|
||||||
|
from orders.models import Order
|
||||||
|
if Order.query.filter(OrderID=merchant_order_sn).exists():
|
||||||
|
return FUBEI_BIZ_ORDER
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return FUBEI_BIZ_CZJILU
|
||||||
|
|
||||||
|
def _fulfill_order(self, out_trade_no: str, transaction_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
from orders.views.payment import WechatPayNotifyView
|
||||||
|
view = WechatPayNotifyView()
|
||||||
|
view._handle_success(out_trade_no, transaction_id, None)
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('[FUBEI_NOTIFY] 订单履约失败 %s: %s', out_trade_no, exc, exc_info=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _fulfill_czjilu(self, out_trade_no: str, total_fee, transaction_id: str) -> bool:
|
||||||
|
from products.models import Czjilu
|
||||||
|
from products.czjilu_types import (
|
||||||
|
CZJILU_PAID_STATUS,
|
||||||
|
is_czjilu_fulfillable_status,
|
||||||
|
)
|
||||||
|
from products.views.alipay_czjilu_notify import _fulfill_czjilu_by_leixing
|
||||||
|
|
||||||
|
order = Czjilu.query.filter(dingdan_id=out_trade_no).first()
|
||||||
|
if not order:
|
||||||
|
logger.error('[FUBEI_NOTIFY] 充值单不存在: %s', out_trade_no)
|
||||||
|
return False
|
||||||
|
if order.zhuangtai == CZJILU_PAID_STATUS:
|
||||||
|
return True
|
||||||
|
if not is_czjilu_fulfillable_status(order.zhuangtai):
|
||||||
|
logger.warning('[FUBEI_NOTIFY] 充值单终态不再履约 %s zhuangtai=%s', out_trade_no, order.zhuangtai)
|
||||||
|
return True
|
||||||
|
paid_amount = total_fee if total_fee not in (None, '') else order.jine
|
||||||
|
try:
|
||||||
|
_fulfill_czjilu_by_leixing(out_trade_no, order.leixing, paid_amount, source='fubei_callback')
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('[FUBEI_NOTIFY] 充值履约失败 %s: %s', out_trade_no, exc, exc_info=True)
|
||||||
|
return False
|
||||||
130
jituan/services/fubei_query.py
Normal file
130
jituan/services/fubei_query.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""付呗支付结果确认:主动查单(轮询/复查兜底,不依赖回调)。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
- 流水表标明付呗 → 只查付呗(绝不误查微信直连)
|
||||||
|
- 无流水且俱乐部当前为付呗 → 先尝试付呗查单;查无单再回落微信
|
||||||
|
- 其它 → 返回 None,由调用方走微信/支付宝
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from jituan.constants import (
|
||||||
|
CLUB_ID_DEFAULT,
|
||||||
|
MINI_PAY_CHANNEL_FUBEI,
|
||||||
|
PAY_CHANNEL_FUBEI_WX_MINI,
|
||||||
|
)
|
||||||
|
from jituan.services.club_payment_channel import get_fubei_config
|
||||||
|
from jituan.services import fubei_client
|
||||||
|
from jituan.services.mini_pay_router import get_club_mini_pay_channel, get_pay_ledger
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_fubei_out_trade_no(out_trade_no: str) -> bool:
|
||||||
|
ledger = get_pay_ledger(out_trade_no)
|
||||||
|
return bool(ledger and ledger.channel == PAY_CHANNEL_FUBEI_WX_MINI)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_try_fubei(out_trade_no: str, club_id: Optional[str]) -> Tuple[bool, str, str]:
|
||||||
|
"""
|
||||||
|
返回 (是否应付呗查单, club_id, provider_order_sn)。
|
||||||
|
有付呗流水 → 强制付呗;无流水但俱乐部选用付呗 → 尝试付呗。
|
||||||
|
"""
|
||||||
|
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||||
|
ledger = get_pay_ledger(out_trade_no)
|
||||||
|
if ledger and ledger.channel == PAY_CHANNEL_FUBEI_WX_MINI:
|
||||||
|
return True, (ledger.club_id or cid), (ledger.provider_order_sn or '')
|
||||||
|
if ledger:
|
||||||
|
# 明确记了其它通道,不查付呗
|
||||||
|
return False, cid, ''
|
||||||
|
if get_club_mini_pay_channel(cid) == MINI_PAY_CHANNEL_FUBEI:
|
||||||
|
return True, cid, ''
|
||||||
|
return False, cid, ''
|
||||||
|
|
||||||
|
|
||||||
|
def query_fubei_trade(
|
||||||
|
out_trade_no: str,
|
||||||
|
club_id: Optional[str] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
查询付呗订单。
|
||||||
|
不应查付呗时返回 None;
|
||||||
|
应查付呗时返回 data(含 trade_state);查单业务失败时抛 Exception。
|
||||||
|
"""
|
||||||
|
try_fb, cid, order_sn = _should_try_fubei(out_trade_no, club_id)
|
||||||
|
if not try_fb:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cfg = get_fubei_config(cid)
|
||||||
|
if not cfg or not cfg.app_id or not cfg.api_key:
|
||||||
|
# 俱乐部标了付呗但配置不齐:有流水则报错;仅「尝试」则回落微信
|
||||||
|
ledger = get_pay_ledger(out_trade_no)
|
||||||
|
if ledger and ledger.channel == PAY_CHANNEL_FUBEI_WX_MINI:
|
||||||
|
logger.error('[FUBEI_QUERY] 配置缺失 club=%s out_trade_no=%s', cid, out_trade_no)
|
||||||
|
raise Exception('付呗查单配置缺失')
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = fubei_client.query_order(
|
||||||
|
gateway_url=cfg.gateway_url,
|
||||||
|
app_id=cfg.app_id,
|
||||||
|
app_secret=cfg.api_key,
|
||||||
|
merchant_order_sn=out_trade_no,
|
||||||
|
order_sn=order_sn,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
'[FUBEI_QUERY] out_trade_no=%s club=%s trade_state=%s',
|
||||||
|
out_trade_no, cid, data.get('trade_state'),
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def fubei_trade_is_success(data: Optional[Dict[str, Any]]) -> bool:
|
||||||
|
if not data:
|
||||||
|
return False
|
||||||
|
state = str(data.get('trade_state') or '').upper()
|
||||||
|
return state == 'SUCCESS'
|
||||||
|
|
||||||
|
|
||||||
|
def fubei_success_payload(
|
||||||
|
out_trade_no: str,
|
||||||
|
club_id: Optional[str] = None,
|
||||||
|
) -> Optional[Tuple[str, Any]]:
|
||||||
|
"""
|
||||||
|
返回值约定(供复查/轮询统一使用):
|
||||||
|
- None:不是付呗单(或不应走付呗),调用方继续微信/支付宝
|
||||||
|
- ('', None):是付呗路径,但尚未支付成功(支付中)
|
||||||
|
- (transaction_id, total_fee):付呗已支付成功
|
||||||
|
"""
|
||||||
|
try_fb, _, _ = _should_try_fubei(out_trade_no, club_id)
|
||||||
|
if not try_fb:
|
||||||
|
return None
|
||||||
|
|
||||||
|
forced = is_fubei_out_trade_no(out_trade_no)
|
||||||
|
try:
|
||||||
|
data = query_fubei_trade(out_trade_no, club_id)
|
||||||
|
except Exception as exc:
|
||||||
|
msg = str(exc)
|
||||||
|
logger.error('[FUBEI_QUERY] 查单异常 %s: %s', out_trade_no, exc, exc_info=True)
|
||||||
|
# 无流水的「尝试」查询:订单不存在等可回落微信直连
|
||||||
|
if not forced and any(k in msg for k in ('不存在', '无此', '未找到', 'ORDER', 'order')):
|
||||||
|
return None
|
||||||
|
# 有流水的付呗单:绝不能回落微信
|
||||||
|
return ('', None)
|
||||||
|
|
||||||
|
if data is None:
|
||||||
|
return None if not forced else ('', None)
|
||||||
|
|
||||||
|
if not fubei_trade_is_success(data):
|
||||||
|
return ('', None)
|
||||||
|
|
||||||
|
txn = str(
|
||||||
|
data.get('trade_no')
|
||||||
|
or data.get('platform_order_no')
|
||||||
|
or data.get('order_sn')
|
||||||
|
or out_trade_no
|
||||||
|
or 'FUBEI_PAID'
|
||||||
|
)
|
||||||
|
total_fee = data.get('total_fee') or data.get('cash_fee') or data.get('order_price')
|
||||||
|
return (txn, total_fee)
|
||||||
251
jituan/services/gift_huiyuan.py
Normal file
251
jituan/services/gift_huiyuan.py
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
"""后台赠送会员(免费接单):与购买会员 Huiyuangoumai 分离。"""
|
||||||
|
from datetime import datetime, time
|
||||||
|
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.dateparse import parse_datetime, parse_date
|
||||||
|
|
||||||
|
from products.models import Huiyuan
|
||||||
|
from users.models import DashouZengsongHuiyuan, UserDashou
|
||||||
|
|
||||||
|
GIFT_HUIYUAN_PERMS = frozenset({
|
||||||
|
'000001', '001aa', '001bb', '001cc', '001dd', '001ee',
|
||||||
|
})
|
||||||
|
TARGET_JIFEN_ON_GIFT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def has_gift_huiyuan_permission(permissions):
|
||||||
|
return any(p in (permissions or []) for p in GIFT_HUIYUAN_PERMS)
|
||||||
|
|
||||||
|
|
||||||
|
def _gift_record_active(record, now=None):
|
||||||
|
if not record or not record.daoqi_time:
|
||||||
|
return False
|
||||||
|
from jituan.services.huiyuan_bundle import _normalize_datetime_for_compare
|
||||||
|
now = _normalize_datetime_for_compare(now or timezone.now())
|
||||||
|
daoqi = _normalize_datetime_for_compare(record.daoqi_time)
|
||||||
|
return daoqi > now
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_any_active_gift_huiyuan(yonghu_id):
|
||||||
|
"""是否持有任意一条未过期的赠送会员(提现/收款资格用)。"""
|
||||||
|
if not yonghu_id:
|
||||||
|
return False
|
||||||
|
now = timezone.now()
|
||||||
|
for rec in DashouZengsongHuiyuan.query.filter(yonghu_id=yonghu_id):
|
||||||
|
if _gift_record_active(rec, now=now):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_gift_daoqi_time(raw):
|
||||||
|
"""
|
||||||
|
解析到期时间。
|
||||||
|
支持:'YYYY-MM-DD HH:MM:SS' / ISO / 'YYYY-MM-DD'(当天 23:59:59)。
|
||||||
|
"""
|
||||||
|
if raw is None or str(raw).strip() == '':
|
||||||
|
return None, '请填写到期时间'
|
||||||
|
text = str(raw).strip()
|
||||||
|
dt = parse_datetime(text.replace('/', '-'))
|
||||||
|
if dt is None:
|
||||||
|
d = parse_date(text.replace('/', '-'))
|
||||||
|
if d is None:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(text, '%Y-%m-%d %H:%M:%S')
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
d = datetime.strptime(text, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return None, '到期时间格式错误,请用 YYYY-MM-DD 或 YYYY-MM-DD HH:MM:SS'
|
||||||
|
dt = datetime.combine(d, time(23, 59, 59))
|
||||||
|
else:
|
||||||
|
dt = datetime.combine(d, time(23, 59, 59))
|
||||||
|
if timezone.is_naive(dt) and timezone.is_aware(timezone.now()):
|
||||||
|
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
||||||
|
elif (not timezone.is_naive(dt)) and (not timezone.is_aware(timezone.now())):
|
||||||
|
dt = timezone.make_naive(dt, timezone.get_current_timezone())
|
||||||
|
if dt <= timezone.now():
|
||||||
|
return None, '到期时间必须晚于当前时间'
|
||||||
|
return dt, None
|
||||||
|
|
||||||
|
|
||||||
|
def list_gift_huiyuan_for_admin(yonghu_id):
|
||||||
|
"""后台详情:全部赠送记录(含已过期),附会员名。"""
|
||||||
|
records = list(
|
||||||
|
DashouZengsongHuiyuan.query.filter(yonghu_id=yonghu_id).order_by('-daoqi_time')
|
||||||
|
)
|
||||||
|
if not records:
|
||||||
|
return []
|
||||||
|
name_map = {
|
||||||
|
h.huiyuan_id: h.jieshao
|
||||||
|
for h in Huiyuan.query.filter(
|
||||||
|
huiyuan_id__in=[r.huiyuan_id for r in records]
|
||||||
|
).only('huiyuan_id', 'jieshao')
|
||||||
|
}
|
||||||
|
now = timezone.now()
|
||||||
|
out = []
|
||||||
|
for r in records:
|
||||||
|
active = _gift_record_active(r, now=now)
|
||||||
|
out.append({
|
||||||
|
'id': r.id,
|
||||||
|
'huiyuan_id': r.huiyuan_id,
|
||||||
|
'huiyuan_name': name_map.get(r.huiyuan_id, f'会员{r.huiyuan_id}'),
|
||||||
|
'daoqi_time': r.daoqi_time.strftime('%Y-%m-%d %H:%M:%S') if r.daoqi_time else '',
|
||||||
|
'song_ren': r.song_ren or '',
|
||||||
|
'song_time': r.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if r.CreateTime else '',
|
||||||
|
'is_active': active,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_gift_huiyuan_access(yonghu_id, required_huiyuan_id, club_id=None):
|
||||||
|
"""抢单资格:赠送会员是否覆盖所需会员类型(支持总会员展开)。"""
|
||||||
|
from jituan.services.huiyuan_bundle import (
|
||||||
|
huiyuan_ids_match,
|
||||||
|
is_bundle_huiyuan,
|
||||||
|
normalize_huiyuan_id,
|
||||||
|
resolve_bundle_included_ids,
|
||||||
|
_included_huiyuan_id_set,
|
||||||
|
)
|
||||||
|
|
||||||
|
req = normalize_huiyuan_id(required_huiyuan_id)
|
||||||
|
if not req:
|
||||||
|
return True
|
||||||
|
req_set = {req, req.lstrip('0') or '0'}
|
||||||
|
now = timezone.now()
|
||||||
|
for rec in DashouZengsongHuiyuan.query.filter(yonghu_id=yonghu_id):
|
||||||
|
if not _gift_record_active(rec, now=now):
|
||||||
|
continue
|
||||||
|
if huiyuan_ids_match(rec.huiyuan_id, req):
|
||||||
|
return True
|
||||||
|
if is_bundle_huiyuan(rec.huiyuan_id):
|
||||||
|
included = resolve_bundle_included_ids(
|
||||||
|
rec.huiyuan_id,
|
||||||
|
club_id=club_id,
|
||||||
|
yonghu_id=yonghu_id,
|
||||||
|
)
|
||||||
|
if req_set & _included_huiyuan_id_set(included):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def build_gift_clumber_items(yonghu_id, club_id=None):
|
||||||
|
"""抢单池 clumber:未过期赠送会员条目。"""
|
||||||
|
from jituan.services.huiyuan_bundle import (
|
||||||
|
_append_bundle_sub_clumber,
|
||||||
|
_append_clumber_id_variants,
|
||||||
|
format_legacy_daoqi,
|
||||||
|
is_bundle_huiyuan,
|
||||||
|
resolve_bundle_included_ids,
|
||||||
|
_resolve_user_club_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
seen = set()
|
||||||
|
effective_club = _resolve_user_club_id(yonghu_id, club_id)
|
||||||
|
now = timezone.now()
|
||||||
|
for rec in DashouZengsongHuiyuan.query.filter(yonghu_id=yonghu_id):
|
||||||
|
if not _gift_record_active(rec, now=now):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
hy = Huiyuan.query.get(huiyuan_id=rec.huiyuan_id)
|
||||||
|
ming = hy.jieshao or f'会员{rec.huiyuan_id}'
|
||||||
|
except Huiyuan.DoesNotExist:
|
||||||
|
ming = f'会员{rec.huiyuan_id}'
|
||||||
|
base = {
|
||||||
|
'huiyuanming': ming,
|
||||||
|
'daoqi': format_legacy_daoqi(rec.daoqi_time),
|
||||||
|
'huiyuan_zhuangtai': 1,
|
||||||
|
'shifou_daoqi': False,
|
||||||
|
'is_gift': True,
|
||||||
|
}
|
||||||
|
if is_bundle_huiyuan(rec.huiyuan_id):
|
||||||
|
base['is_bundle'] = True
|
||||||
|
base['bundle_huiyuan_id'] = rec.huiyuan_id
|
||||||
|
base['included_huiyuan_ids'] = resolve_bundle_included_ids(
|
||||||
|
rec.huiyuan_id,
|
||||||
|
club_id=effective_club,
|
||||||
|
yonghu_id=yonghu_id,
|
||||||
|
)
|
||||||
|
_append_clumber_id_variants(items, seen, base, rec.huiyuan_id)
|
||||||
|
_append_bundle_sub_clumber(items, seen, base)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_gift_huiyuan(*, yonghu_id, huiyuan_id, daoqi_time, song_ren):
|
||||||
|
"""
|
||||||
|
赠送或改期。同一打手同一会员类型仅一条记录。
|
||||||
|
返回 (record, created, jifen_changed_info_or_None, error_msg)
|
||||||
|
jifen_changed_info = {'before': x, 'after': 10} 若调整了积分
|
||||||
|
"""
|
||||||
|
huiyuan_id = str(huiyuan_id or '').strip()
|
||||||
|
if not huiyuan_id:
|
||||||
|
return None, False, None, '缺少会员ID'
|
||||||
|
if not Huiyuan.query.filter(huiyuan_id=huiyuan_id).exists():
|
||||||
|
return None, False, None, '会员类型不存在'
|
||||||
|
|
||||||
|
jifen_info = None
|
||||||
|
with transaction.atomic():
|
||||||
|
try:
|
||||||
|
dashou = UserDashou.objects.select_for_update().select_related('user').get(
|
||||||
|
user__UserUID=yonghu_id
|
||||||
|
)
|
||||||
|
except UserDashou.DoesNotExist:
|
||||||
|
return None, False, None, '打手不存在'
|
||||||
|
|
||||||
|
before_jifen = int(dashou.jifen or 0)
|
||||||
|
if before_jifen != TARGET_JIFEN_ON_GIFT:
|
||||||
|
dashou.jifen = TARGET_JIFEN_ON_GIFT
|
||||||
|
dashou.save(update_fields=['jifen'])
|
||||||
|
jifen_info = {'before': before_jifen, 'after': TARGET_JIFEN_ON_GIFT}
|
||||||
|
|
||||||
|
locked_qs = DashouZengsongHuiyuan.objects.select_for_update().filter(
|
||||||
|
yonghu_id=yonghu_id, huiyuan_id=huiyuan_id
|
||||||
|
)
|
||||||
|
existing = locked_qs.first()
|
||||||
|
if existing:
|
||||||
|
existing.daoqi_time = daoqi_time
|
||||||
|
existing.song_ren = song_ren
|
||||||
|
existing.save(update_fields=['daoqi_time', 'song_ren', 'UpdateTime'])
|
||||||
|
return existing, False, jifen_info, None
|
||||||
|
|
||||||
|
record = DashouZengsongHuiyuan.query.create(
|
||||||
|
yonghu_id=yonghu_id,
|
||||||
|
huiyuan_id=huiyuan_id,
|
||||||
|
daoqi_time=daoqi_time,
|
||||||
|
song_ren=song_ren,
|
||||||
|
)
|
||||||
|
return record, True, jifen_info, None
|
||||||
|
|
||||||
|
|
||||||
|
def update_gift_huiyuan_daoqi(*, record_id, yonghu_id, daoqi_time, song_ren):
|
||||||
|
"""按记录 ID 改期。"""
|
||||||
|
try:
|
||||||
|
rec = DashouZengsongHuiyuan.query.get(id=record_id)
|
||||||
|
except DashouZengsongHuiyuan.DoesNotExist:
|
||||||
|
return None, '赠送会员记录不存在'
|
||||||
|
if str(rec.yonghu_id) != str(yonghu_id):
|
||||||
|
return None, '记录与打手不匹配'
|
||||||
|
rec.daoqi_time = daoqi_time
|
||||||
|
if song_ren:
|
||||||
|
rec.song_ren = song_ren
|
||||||
|
rec.save(update_fields=['daoqi_time', 'song_ren', 'UpdateTime'])
|
||||||
|
return rec, None
|
||||||
|
|
||||||
|
|
||||||
|
def remove_gift_huiyuan(*, record_id=None, yonghu_id=None, huiyuan_id=None):
|
||||||
|
"""按记录 ID,或 (yonghu_id, huiyuan_id) 移除。"""
|
||||||
|
qs = DashouZengsongHuiyuan.query
|
||||||
|
if record_id:
|
||||||
|
deleted, _ = qs.filter(id=record_id).delete()
|
||||||
|
if not deleted:
|
||||||
|
return False, '赠送会员记录不存在'
|
||||||
|
return True, None
|
||||||
|
yonghu_id = str(yonghu_id or '').strip()
|
||||||
|
huiyuan_id = str(huiyuan_id or '').strip()
|
||||||
|
if not yonghu_id or not huiyuan_id:
|
||||||
|
return False, '缺少记录ID或会员ID'
|
||||||
|
deleted, _ = qs.filter(yonghu_id=yonghu_id, huiyuan_id=huiyuan_id).delete()
|
||||||
|
if not deleted:
|
||||||
|
return False, '赠送会员记录不存在'
|
||||||
|
return True, None
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
"""集团级资金处置权限:提现审核、加余额等。俱乐部 000001 超级管理不能绕过。"""
|
"""集团级资金处置权限:加余额等仍仅集团高管。
|
||||||
|
|
||||||
|
提现审核:权限码 005bb + 按俱乐部隔离——
|
||||||
|
- 子公司视图:持有 005bb 且任职含当前俱乐部 → 可审本俱乐部(同意/拒绝/自动打款)
|
||||||
|
- 集团汇总视图 / 跨俱乐部:须集团资金处置权(GROUP_OWNER / GROUP_SUPER_ADMIN)
|
||||||
|
俱乐部本地 000001 不能绕过「跨店」闸门,但可审本店提现。
|
||||||
|
"""
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
|
|
||||||
from jituan.constants import DATA_SCOPE_ALL
|
from jituan.constants import DATA_SCOPE_ALL
|
||||||
from jituan.models import AdminAssignment
|
from jituan.models import AdminAssignment
|
||||||
from jituan.services.admin_context import GROUP_MANAGE_ROLES, build_admin_club_context, is_system_super_admin
|
from jituan.services.admin_context import (
|
||||||
|
GROUP_MANAGE_ROLES,
|
||||||
|
build_admin_club_context,
|
||||||
|
get_allowed_club_ids,
|
||||||
|
is_system_super_admin,
|
||||||
|
)
|
||||||
|
|
||||||
MSG_GROUP_FINANCE_REQUIRED = '该操作仅集团高管可执行(需在数据范围配置中为集团级任职)'
|
MSG_GROUP_FINANCE_REQUIRED = '该操作仅集团高管可执行(需在数据范围配置中为集团级任职)'
|
||||||
|
MSG_WITHDRAW_CROSS_CLUB = '跨俱乐部提现审核仅集团高管可执行,请切换到具体俱乐部后再操作'
|
||||||
|
MSG_WITHDRAW_CLUB_DENIED = '无权审核该俱乐部提现'
|
||||||
|
|
||||||
|
|
||||||
def has_group_finance_exec(user, request=None):
|
def has_group_finance_exec(user, request=None):
|
||||||
@@ -35,16 +48,32 @@ def deny_group_finance_exec(user, request=None):
|
|||||||
|
|
||||||
|
|
||||||
def require_withdraw_audit_access(request, phone):
|
def require_withdraw_audit_access(request, phone):
|
||||||
"""提现审核:须 005bb 且集团资金处置权。"""
|
"""
|
||||||
|
提现审核访问:
|
||||||
|
1) 须权限码 005bb
|
||||||
|
2) 集团高管:可在 ALL_CLUBS 或任意子公司视图操作
|
||||||
|
3) 俱乐部管理员:仅 SINGLE_CLUB 且当前 club 在其任职范围内
|
||||||
|
"""
|
||||||
from backend.utils import verify_kefu_permission
|
from backend.utils import verify_kefu_permission
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope
|
||||||
|
|
||||||
kefu, permissions = verify_kefu_permission(request, phone)
|
kefu, permissions = verify_kefu_permission(request, phone)
|
||||||
if kefu is None:
|
if kefu is None:
|
||||||
return None, permissions
|
return None, permissions
|
||||||
if '005bb' not in permissions:
|
if '005bb' not in permissions:
|
||||||
return None, Response({'code': 403, 'msg': '您无权操作提现审核'})
|
return None, Response({'code': 403, 'msg': '您无权操作提现审核'})
|
||||||
deny = deny_group_finance_exec(request.user, request)
|
|
||||||
if deny:
|
if has_group_finance_exec(request.user, request):
|
||||||
return None, deny
|
return kefu, None
|
||||||
|
|
||||||
|
scope = resolve_club_scope(request)
|
||||||
|
if scope == DATA_SCOPE_ALL:
|
||||||
|
return None, Response({'code': 403, 'msg': MSG_WITHDRAW_CROSS_CLUB})
|
||||||
|
|
||||||
|
club_id = resolve_club_id_from_request(request)
|
||||||
|
allowed = get_allowed_club_ids(request.user)
|
||||||
|
if not club_id or club_id not in allowed:
|
||||||
|
return None, Response({'code': 403, 'msg': MSG_WITHDRAW_CLUB_DENIED})
|
||||||
|
|
||||||
return kefu, None
|
return kefu, None
|
||||||
|
|||||||
@@ -71,12 +71,27 @@ def get_bundle_included_ids(club_id, bundle_huiyuan_id):
|
|||||||
|
|
||||||
if not club_id or not bundle_huiyuan_id:
|
if not club_id or not bundle_huiyuan_id:
|
||||||
return []
|
return []
|
||||||
return list(
|
for bid in expand_huiyuan_id_variants(bundle_huiyuan_id):
|
||||||
ClubHuiyuanBundleInclude.query.filter(
|
ids = list(
|
||||||
club_id=club_id,
|
ClubHuiyuanBundleInclude.query.filter(
|
||||||
bundle_huiyuan_id=bundle_huiyuan_id,
|
club_id=club_id,
|
||||||
).values_list('included_huiyuan_id', flat=True)
|
bundle_huiyuan_id=bid,
|
||||||
)
|
).values_list('included_huiyuan_id', flat=True)
|
||||||
|
)
|
||||||
|
if ids:
|
||||||
|
return ids
|
||||||
|
stored_bids = ClubHuiyuanBundleInclude.objects.filter(
|
||||||
|
club_id=club_id,
|
||||||
|
).values_list('bundle_huiyuan_id', flat=True).distinct()
|
||||||
|
for stored_bid in stored_bids:
|
||||||
|
if huiyuan_ids_match(stored_bid, bundle_huiyuan_id):
|
||||||
|
return list(
|
||||||
|
ClubHuiyuanBundleInclude.query.filter(
|
||||||
|
club_id=club_id,
|
||||||
|
bundle_huiyuan_id=stored_bid,
|
||||||
|
).values_list('included_huiyuan_id', flat=True)
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def _membership_club_candidates(yonghu_id, club_id=None, record_club_id=None):
|
def _membership_club_candidates(yonghu_id, club_id=None, record_club_id=None):
|
||||||
@@ -108,25 +123,32 @@ def _membership_club_candidates(yonghu_id, club_id=None, record_club_id=None):
|
|||||||
|
|
||||||
|
|
||||||
def resolve_bundle_included_ids(bundle_huiyuan_id, club_id=None, yonghu_id=None, record_club_id=None):
|
def resolve_bundle_included_ids(bundle_huiyuan_id, club_id=None, yonghu_id=None, record_club_id=None):
|
||||||
"""按候选俱乐部解析总会员包含的子会员(只读,首个非空配置即返回)。"""
|
"""按候选俱乐部合并总会员包含的子会员(去重,只读)。"""
|
||||||
if not bundle_huiyuan_id:
|
if not bundle_huiyuan_id:
|
||||||
return []
|
return []
|
||||||
|
merged = []
|
||||||
|
seen = set()
|
||||||
for cid in _membership_club_candidates(
|
for cid in _membership_club_candidates(
|
||||||
yonghu_id, club_id=club_id, record_club_id=record_club_id,
|
yonghu_id, club_id=club_id, record_club_id=record_club_id,
|
||||||
):
|
):
|
||||||
ids = get_bundle_included_ids(cid, bundle_huiyuan_id)
|
for hid in get_bundle_included_ids(cid, bundle_huiyuan_id):
|
||||||
if ids:
|
norm = normalize_huiyuan_id(hid)
|
||||||
return list(ids)
|
core = norm.lstrip('0') or '0' if norm else ''
|
||||||
return []
|
dedupe_key = core or norm
|
||||||
|
if dedupe_key and dedupe_key not in seen:
|
||||||
|
seen.add(dedupe_key)
|
||||||
|
merged.append(hid)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
def _included_huiyuan_id_set(included_ids):
|
def _included_huiyuan_id_set(included_ids):
|
||||||
included_set = set()
|
included_set = set()
|
||||||
for x in included_ids or []:
|
for x in included_ids or []:
|
||||||
sx = normalize_huiyuan_id(x)
|
for variant in expand_huiyuan_id_variants(x):
|
||||||
if sx:
|
sx = normalize_huiyuan_id(variant)
|
||||||
included_set.add(sx)
|
if sx:
|
||||||
included_set.add(sx.lstrip('0') or '0')
|
included_set.add(sx)
|
||||||
|
included_set.add(sx.lstrip('0') or '0')
|
||||||
return included_set
|
return included_set
|
||||||
|
|
||||||
|
|
||||||
@@ -159,10 +181,13 @@ def _record_club_id(record, club_id=None):
|
|||||||
|
|
||||||
|
|
||||||
def is_bundle_huiyuan(huiyuan_id):
|
def is_bundle_huiyuan(huiyuan_id):
|
||||||
try:
|
hid = normalize_huiyuan_id(huiyuan_id)
|
||||||
return bool(Huiyuan.query.get(huiyuan_id=huiyuan_id).is_bundle)
|
if not hid:
|
||||||
except Huiyuan.DoesNotExist:
|
|
||||||
return False
|
return False
|
||||||
|
for bid in Huiyuan.query.filter(is_bundle=True).values_list('huiyuan_id', flat=True):
|
||||||
|
if huiyuan_ids_match(bid, hid):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _membership_record_active(record):
|
def _membership_record_active(record):
|
||||||
@@ -188,18 +213,13 @@ def user_has_huiyuan_access(yonghu_id, required_huiyuan_id, club_id=None):
|
|||||||
def _record_valid(rec):
|
def _record_valid(rec):
|
||||||
return _membership_record_active(rec)
|
return _membership_record_active(rec)
|
||||||
|
|
||||||
|
req_set = {req, req.lstrip('0') or '0'}
|
||||||
for rec in Huiyuangoumai.query.filter(yonghu_id=yonghu_id):
|
for rec in Huiyuangoumai.query.filter(yonghu_id=yonghu_id):
|
||||||
if _record_valid(rec) and huiyuan_ids_match(rec.huiyuan_id, req):
|
if not _record_valid(rec):
|
||||||
|
continue
|
||||||
|
if huiyuan_ids_match(rec.huiyuan_id, req):
|
||||||
return True
|
return True
|
||||||
|
if is_bundle_huiyuan(rec.huiyuan_id):
|
||||||
bundle_ids = set(
|
|
||||||
Huiyuan.query.filter(is_bundle=True).values_list('huiyuan_id', flat=True)
|
|
||||||
)
|
|
||||||
if bundle_ids:
|
|
||||||
req_set = {req, req.lstrip('0') or '0'}
|
|
||||||
for rec in Huiyuangoumai.query.filter(yonghu_id=yonghu_id, huiyuan_id__in=bundle_ids):
|
|
||||||
if not _record_valid(rec):
|
|
||||||
continue
|
|
||||||
included = resolve_bundle_included_ids(
|
included = resolve_bundle_included_ids(
|
||||||
rec.huiyuan_id,
|
rec.huiyuan_id,
|
||||||
club_id=club_id,
|
club_id=club_id,
|
||||||
@@ -211,6 +231,10 @@ def user_has_huiyuan_access(yonghu_id, required_huiyuan_id, club_id=None):
|
|||||||
|
|
||||||
if _czjilu_grants_huiyuan_access(yonghu_id, req, club_id):
|
if _czjilu_grants_huiyuan_access(yonghu_id, req, club_id):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
from jituan.services.gift_huiyuan import user_has_gift_huiyuan_access
|
||||||
|
if user_has_gift_huiyuan_access(yonghu_id, req, club_id=club_id):
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -478,26 +502,33 @@ def _build_clumber_from_paid_czjilu(yonghu_id, club_id=None):
|
|||||||
|
|
||||||
|
|
||||||
def _finalize_clumber_for_legacy_client(items):
|
def _finalize_clumber_for_legacy_client(items):
|
||||||
"""老端只认 huiyuanid;去掉 is_trial 等内部字段。"""
|
"""老端只认 huiyuanid;去掉 is_trial / is_gift 等内部字段。"""
|
||||||
cleaned = []
|
cleaned = []
|
||||||
for it in items or []:
|
for it in items or []:
|
||||||
row = dict(it)
|
row = dict(it)
|
||||||
row.pop('is_trial', None)
|
row.pop('is_trial', None)
|
||||||
|
row.pop('is_gift', None)
|
||||||
cleaned.append(row)
|
cleaned.append(row)
|
||||||
return cleaned
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
def ensure_grab_pool_clumber(yonghu_id, club_id=None):
|
def ensure_grab_pool_clumber(yonghu_id, club_id=None):
|
||||||
"""
|
"""
|
||||||
dddhq / dashouxinxi:只读 huiyuangoumai + 有效期内充值单 生成 clumber(体验=正式)。
|
dddhq / dashouxinxi:只读 huiyuangoumai + 有效期内充值单 + 赠送会员 生成 clumber。
|
||||||
总会员展开子会员虚拟条目供老端 clumber 比对;不写库、不补开通。
|
总会员展开子会员虚拟条目供老端 clumber 比对;不写库、不补开通。
|
||||||
"""
|
"""
|
||||||
|
from jituan.services.gift_huiyuan import build_gift_clumber_items
|
||||||
|
|
||||||
effective_club = _resolve_user_club_id(yonghu_id, club_id)
|
effective_club = _resolve_user_club_id(yonghu_id, club_id)
|
||||||
items = _build_user_clumber_core(yonghu_id, club_id=effective_club)
|
items = _build_user_clumber_core(yonghu_id, club_id=effective_club)
|
||||||
items = _merge_clumber_extra_items(
|
items = _merge_clumber_extra_items(
|
||||||
items,
|
items,
|
||||||
_build_clumber_from_paid_czjilu(yonghu_id, club_id=effective_club),
|
_build_clumber_from_paid_czjilu(yonghu_id, club_id=effective_club),
|
||||||
)
|
)
|
||||||
|
items = _merge_clumber_extra_items(
|
||||||
|
items,
|
||||||
|
build_gift_clumber_items(yonghu_id, club_id=effective_club),
|
||||||
|
)
|
||||||
items = _finalize_clumber_for_legacy_client(items)
|
items = _finalize_clumber_for_legacy_client(items)
|
||||||
return bool(items), items
|
return bool(items), items
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ def assert_invite_same_club(request, inviter_user, invitee_user=None):
|
|||||||
'invite_guard 管事俱乐部不符 inviter=%s inviter_club=%s req_club=%s',
|
'invite_guard 管事俱乐部不符 inviter=%s inviter_club=%s req_club=%s',
|
||||||
getattr(inviter_user, 'UserUID', '?'), inviter_club, club_id,
|
getattr(inviter_user, 'UserUID', '?'), inviter_club, club_id,
|
||||||
)
|
)
|
||||||
return False, '邀请码不属于当前小程序俱乐部'
|
# 文案带上两侧 club,方便排查「种子 ClubID ≠ 小程序 X-Club-Id」
|
||||||
|
return False, (
|
||||||
|
f'邀请码不属于当前小程序俱乐部'
|
||||||
|
f'(邀请人归属={inviter_club or "空"},当前小程序={club_id or "空"})'
|
||||||
|
)
|
||||||
|
|
||||||
if invitee_user is not None:
|
if invitee_user is not None:
|
||||||
invitee_club = resolve_invite_party_club(invitee_user, club_id)
|
invitee_club = resolve_invite_party_club(invitee_user, club_id)
|
||||||
|
|||||||
@@ -30,10 +30,14 @@ def _has_super_perm(permissions):
|
|||||||
|
|
||||||
|
|
||||||
def _page_visible(page, permissions, user):
|
def _page_visible(page, permissions, user):
|
||||||
|
page_id = getattr(page, 'page_id', '') or ''
|
||||||
|
|
||||||
if page.require_group_manage:
|
if page.require_group_manage:
|
||||||
return can_manage_admin_assignments(user)
|
return can_manage_admin_assignments(user)
|
||||||
|
|
||||||
if getattr(page, 'require_admin_user_manage', False):
|
# 后台用户:本店超级管理 000001 / 集团 / 系统超管均可。
|
||||||
|
# 注意:历史上 DB 模型缺 require_admin_user_manage 字段,导致该页永远不可见,必须按 page_id 兜底。
|
||||||
|
if page_id == 'admin.user' or getattr(page, 'require_admin_user_manage', False):
|
||||||
return can_access_admin_user_management(user, permissions)
|
return can_access_admin_user_management(user, permissions)
|
||||||
|
|
||||||
if page.require_super:
|
if page.require_super:
|
||||||
@@ -92,7 +96,7 @@ def _load_menu_pages():
|
|||||||
pages = []
|
pages = []
|
||||||
|
|
||||||
need_seed = not pages or not any(
|
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:
|
if need_seed:
|
||||||
try:
|
try:
|
||||||
@@ -165,6 +169,7 @@ def build_menu_access_payload(request, username=None):
|
|||||||
|
|
||||||
path_by_id = {p.page_id: p.path for p in pages if p.path}
|
path_by_id = {p.page_id: p.path for p in pages if p.path}
|
||||||
visible_paths = [path_by_id[pid] for pid in visible_ids if pid in path_by_id]
|
visible_paths = [path_by_id[pid] for pid in visible_ids if pid in path_by_id]
|
||||||
|
is_super = _has_super_perm(permissions) or is_system_super_admin(request.user)
|
||||||
return {
|
return {
|
||||||
'visible_page_ids': sorted(set(visible_ids)),
|
'visible_page_ids': sorted(set(visible_ids)),
|
||||||
'visible_paths': visible_paths,
|
'visible_paths': visible_paths,
|
||||||
@@ -178,6 +183,7 @@ def build_menu_access_payload(request, username=None):
|
|||||||
'roles_in_scope': roles_detail,
|
'roles_in_scope': roles_detail,
|
||||||
'can_switch_club': bool(club_ctx.get('can_switch_club')),
|
'can_switch_club': bool(club_ctx.get('can_switch_club')),
|
||||||
'is_group_admin': bool(club_ctx.get('is_group_admin')),
|
'is_group_admin': bool(club_ctx.get('is_group_admin')),
|
||||||
|
'is_super': bool(is_super),
|
||||||
'menu_ready': True,
|
'menu_ready': True,
|
||||||
'menu_source': menu_source,
|
'menu_source': menu_source,
|
||||||
}, None
|
}, None
|
||||||
|
|||||||
@@ -54,22 +54,28 @@ KEFU_MENU_ROW_DEFS = [
|
|||||||
'require_group_manage': True},
|
'require_group_manage': True},
|
||||||
{'page_id': 'admin.club-config', 'name': '俱乐部密钥配置', 'path': '/admin/club-config', 'parent_id': 'admin', 'sort_order': 73,
|
{'page_id': 'admin.club-config', 'name': '俱乐部密钥配置', 'path': '/admin/club-config', 'parent_id': 'admin', 'sort_order': 73,
|
||||||
'require_group_manage': True},
|
'require_group_manage': True},
|
||||||
|
{'page_id': 'admin.order-grab-share', 'name': '订单互通抢单', 'path': '/admin/order-grab-share', 'parent_id': 'admin', 'sort_order': 76,
|
||||||
|
'require_group_manage': True},
|
||||||
{'page_id': 'admin.user', 'name': '后台用户', 'path': '/admin/user', 'parent_id': 'admin', 'sort_order': 74,
|
{'page_id': 'admin.user', 'name': '后台用户', 'path': '/admin/user', 'parent_id': 'admin', 'sort_order': 74,
|
||||||
'require_admin_user_manage': True},
|
'require_super': True, 'require_admin_user_manage': True},
|
||||||
{'page_id': 'admin.operation-log', 'name': '操作日志', 'path': '/admin/operation-log', 'parent_id': 'admin', 'sort_order': 75,
|
{'page_id': 'admin.operation-log', 'name': '操作日志', 'path': '/admin/operation-log', 'parent_id': 'admin', 'sort_order': 75,
|
||||||
'perm_codes': ['czrz666']},
|
'perm_codes': ['czrz666']},
|
||||||
{'page_id': 'punishment', 'name': '处罚管理', 'path': '/punishment', 'parent_id': '', 'sort_order': 80,
|
{'page_id': 'punishment', 'name': '处罚管理', 'path': '/punishment', 'parent_id': '', 'sort_order': 80,
|
||||||
'perm_codes': ['005aa', '66693a', '66693b', '66693c', '66694c', '99933abs']},
|
'perm_codes': ['005aa', '66693a', '66693b', '66693c', '66694c', '99933abs']},
|
||||||
{'page_id': 'punishment.detail', 'name': '积分处罚详情', 'path': '/punishment/detail', 'parent_id': 'punishment', 'sort_order': 81,
|
{'page_id': 'punishment.detail', 'name': '积分处罚详情', 'path': '/punishment/detail', 'parent_id': 'punishment', 'sort_order': 81,
|
||||||
'perm_codes': ['005aa', '66693a', '66693b', '66693c', '66694c', '99933abs']},
|
'perm_codes': ['005aa', '66693a', '66693b', '66693c', '66694c', '99933abs']},
|
||||||
|
{'page_id': 'gongdan', 'name': '投诉工单', 'path': '/gongdan', 'parent_id': '', 'sort_order': 82,
|
||||||
|
'perm_codes': ['gdck666', 'gdcl666']},
|
||||||
{'page_id': 'product', 'name': '商品管理', 'path': '', 'parent_id': '', 'sort_order': 90},
|
{'page_id': 'product', 'name': '商品管理', 'path': '', 'parent_id': '', 'sort_order': 90},
|
||||||
{'page_id': 'product.list', 'name': '商品列表', 'path': '/product/list', 'parent_id': 'product', 'sort_order': 91,
|
{'page_id': 'product.list', 'name': '商品列表', 'path': '/product/list', 'parent_id': 'product', 'sort_order': 91,
|
||||||
'perm_codes': ['2200a']},
|
'perm_codes': ['2200a']},
|
||||||
{'page_id': 'product.type-zone', 'name': '商品类型专区管理', 'path': '/product/type-zone', 'parent_id': 'product', 'sort_order': 92,
|
{'page_id': 'product.type-zone', 'name': '商品类型专区管理', 'path': '/product/type-zone', 'parent_id': 'product', 'sort_order': 92,
|
||||||
'perm_codes': ['7007a']},
|
'perm_codes': ['7007a']},
|
||||||
{'page_id': 'product.data', 'name': '商品数据分析', 'path': '/product/data', 'parent_id': 'product', 'sort_order': 93,
|
{'page_id': 'product.turntable', 'name': '转盘活动', 'path': '/product/turntable', 'parent_id': 'product', 'sort_order': 93,
|
||||||
'perm_codes': ['2200a']},
|
'perm_codes': ['2200a']},
|
||||||
{'page_id': 'product.fake-orders', 'name': '假单管理', 'path': '/product/fake-orders', 'parent_id': 'product', 'sort_order': 94,
|
{'page_id': 'product.data', 'name': '商品数据分析', 'path': '/product/data', 'parent_id': 'product', 'sort_order': 94,
|
||||||
|
'perm_codes': ['2200a']},
|
||||||
|
{'page_id': 'product.fake-orders', 'name': '假单管理', 'path': '/product/fake-orders', 'parent_id': 'product', 'sort_order': 95,
|
||||||
'perm_codes': ['2200a']},
|
'perm_codes': ['2200a']},
|
||||||
{'page_id': 'member', 'name': '会员管理', 'path': '', 'parent_id': '', 'sort_order': 100},
|
{'page_id': 'member', 'name': '会员管理', 'path': '', 'parent_id': '', 'sort_order': 100},
|
||||||
{'page_id': 'member.list', 'name': '会员管理', 'path': '/member/list', 'parent_id': 'member', 'sort_order': 101,
|
{'page_id': 'member.list', 'name': '会员管理', 'path': '/member/list', 'parent_id': 'member', 'sort_order': 101,
|
||||||
@@ -93,6 +99,8 @@ KEFU_MENU_ROW_DEFS = [
|
|||||||
'perm_codes': ['dsks666', '8080a']},
|
'perm_codes': ['dsks666', '8080a']},
|
||||||
{'page_id': 'miniapp.rank-reward', 'name': '排行榜奖励', 'path': '/miniapp/rank-reward', 'parent_id': 'miniapp', 'sort_order': 118,
|
{'page_id': 'miniapp.rank-reward', 'name': '排行榜奖励', 'path': '/miniapp/rank-reward', 'parent_id': 'miniapp', 'sort_order': 118,
|
||||||
'perm_codes': ['phbj666', '8080a']},
|
'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', 'name': '店铺管理', 'path': '', 'parent_id': '', 'sort_order': 120},
|
||||||
{'page_id': 'shop.list', 'name': '店铺列表', 'path': '/shop/list', 'parent_id': 'shop', 'sort_order': 121,
|
{'page_id': 'shop.list', 'name': '店铺列表', 'path': '/shop/list', 'parent_id': 'shop', 'sort_order': 121,
|
||||||
'perm_codes': ['999a', '999b', '999c', '999d', '999e']},
|
'perm_codes': ['999a', '999b', '999c', '999d', '999e']},
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ MEMBER_PAID_STATUS = 3
|
|||||||
MEMBER_PENDING_STATUS = 9
|
MEMBER_PENDING_STATUS = 9
|
||||||
MEMBER_FAILED_STATUS = 8
|
MEMBER_FAILED_STATUS = 8
|
||||||
MEMBER_CANCELLED_STATUS = 2
|
MEMBER_CANCELLED_STATUS = 2
|
||||||
|
# 与 czjilu_types.CZJILU_FULFILLABLE_STATUSES 一致:仅这些状态允许入账
|
||||||
|
MEMBER_FULFILLABLE_STATUSES = (MEMBER_PENDING_STATUS, MEMBER_CANCELLED_STATUS)
|
||||||
|
MEMBER_CANCELLED_STATUS = 2
|
||||||
ENTITLEMENT_REVOKED_MARKER = '权益已撤销'
|
ENTITLEMENT_REVOKED_MARKER = '权益已撤销'
|
||||||
MEMBER_JIFEN_CAP = 10
|
MEMBER_JIFEN_CAP = 10
|
||||||
|
|
||||||
@@ -923,7 +926,7 @@ def fulfill_member_recharge(dingdan_id, paid_amount_yuan=None, source='callback'
|
|||||||
if order.leixing != MEMBER_LEIXING:
|
if order.leixing != MEMBER_LEIXING:
|
||||||
raise MemberRechargeError('订单类型不是会员充值', 'type_error')
|
raise MemberRechargeError('订单类型不是会员充值', 'type_error')
|
||||||
|
|
||||||
if order.zhuangtai != MEMBER_PENDING_STATUS:
|
if order.zhuangtai not in MEMBER_FULFILLABLE_STATUSES:
|
||||||
logger.info(
|
logger.info(
|
||||||
'会员订单已处理,跳过履约 dingdan=%s zhuangtai=%s source=%s',
|
'会员订单已处理,跳过履约 dingdan=%s zhuangtai=%s source=%s',
|
||||||
dingdan_id, order.zhuangtai, source,
|
dingdan_id, order.zhuangtai, source,
|
||||||
@@ -1093,21 +1096,18 @@ def confirm_member_recharge_paid(dingdan_id, yonghuid):
|
|||||||
'is_trial': goumai.is_trial,
|
'is_trial': goumai.is_trial,
|
||||||
}
|
}
|
||||||
|
|
||||||
if order.zhuangtai != MEMBER_PENDING_STATUS:
|
if order.zhuangtai != MEMBER_PENDING_STATUS and order.zhuangtai != MEMBER_CANCELLED_STATUS:
|
||||||
raise MemberRechargeError(f'订单状态异常: {order.zhuangtai}', 'status_error')
|
raise MemberRechargeError(f'订单状态异常: {order.zhuangtai}', 'status_error')
|
||||||
|
|
||||||
# 支付宝订单:异步回调会更新状态,轮询时只看本地状态,未支付则提示稍后重试
|
club_id = getattr(order, 'club_id', None)
|
||||||
from django.core.cache import cache
|
from jituan.services.pay_confirm import confirm_remote_payment
|
||||||
if cache.get(f'alipay_pay_url_{dingdan_id}') is not None:
|
remote = confirm_remote_payment(dingdan_id, club_id, prefer_amount=order.jine)
|
||||||
raise MemberRechargeError('支付宝支付结果尚未同步,请稍后再试', 'not_paid')
|
if not remote.get('ok'):
|
||||||
|
if remote.get('pending'):
|
||||||
|
raise MemberRechargeError('支付结果尚未同步,请稍后再试', 'not_paid')
|
||||||
|
raise MemberRechargeError(remote.get('msg') or '支付未成功', 'not_paid')
|
||||||
|
|
||||||
trade_state, _ = query_wechat_v2_trade_state(
|
total_fee_yuan = Decimal(str(remote.get('amount') if remote.get('amount') is not None else order.jine))
|
||||||
dingdan_id, getattr(order, 'club_id', None),
|
|
||||||
)
|
|
||||||
if trade_state != 'SUCCESS':
|
|
||||||
raise MemberRechargeError('微信侧尚未支付成功,请稍后再试', 'not_paid')
|
|
||||||
|
|
||||||
total_fee_yuan = Decimal(str(order.jine))
|
|
||||||
result = fulfill_member_recharge(
|
result = fulfill_member_recharge(
|
||||||
dingdan_id, paid_amount_yuan=total_fee_yuan, source='confirm',
|
dingdan_id, paid_amount_yuan=total_fee_yuan, source='confirm',
|
||||||
)
|
)
|
||||||
|
|||||||
152
jituan/services/mini_pay_router.py
Normal file
152
jituan/services/mini_pay_router.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""小程序「微信支付」通道路由:按俱乐部选择微信直连或付呗;前端仍传 pay_method=wechat。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from jituan.constants import (
|
||||||
|
CLUB_ID_DEFAULT,
|
||||||
|
FUBEI_BIZ_CZJILU,
|
||||||
|
FUBEI_BIZ_ORDER,
|
||||||
|
MINI_PAY_CHANNEL_FUBEI,
|
||||||
|
MINI_PAY_CHANNEL_WECHAT,
|
||||||
|
PAY_CHANNEL_FUBEI_WX_MINI,
|
||||||
|
)
|
||||||
|
from jituan.models import Club, PayChannelLedger
|
||||||
|
from jituan.services.club_payment_channel import get_fubei_config, invalidate_club_payment_cache
|
||||||
|
from jituan.services import fubei_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_club_mini_pay_channel(club_id: Optional[str] = None) -> str:
|
||||||
|
"""返回 wechat / fubei;缺省 wechat(现网行为)。"""
|
||||||
|
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||||
|
club = Club.query.filter(club_id=cid).first()
|
||||||
|
if not club:
|
||||||
|
return MINI_PAY_CHANNEL_WECHAT
|
||||||
|
ch = (getattr(club, 'mini_pay_channel', None) or MINI_PAY_CHANNEL_WECHAT).strip().lower()
|
||||||
|
if ch == MINI_PAY_CHANNEL_FUBEI:
|
||||||
|
return MINI_PAY_CHANNEL_FUBEI
|
||||||
|
return MINI_PAY_CHANNEL_WECHAT
|
||||||
|
|
||||||
|
|
||||||
|
def set_club_mini_pay_channel(club_id: str, channel: str) -> None:
|
||||||
|
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||||
|
ch = (channel or '').strip().lower()
|
||||||
|
if ch not in (MINI_PAY_CHANNEL_WECHAT, MINI_PAY_CHANNEL_FUBEI):
|
||||||
|
raise ValueError('mini_pay_channel 仅支持 wechat / fubei')
|
||||||
|
club = Club.query.filter(club_id=cid).first()
|
||||||
|
if not club:
|
||||||
|
raise ValueError(f'俱乐部 {cid} 不存在')
|
||||||
|
club.mini_pay_channel = ch
|
||||||
|
club.save(update_fields=['mini_pay_channel'])
|
||||||
|
invalidate_club_payment_cache(cid)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_fubei_notify_url(club_id: Optional[str] = None) -> str:
|
||||||
|
base = 'https://www.abas.asia'
|
||||||
|
club = Club.query.filter(club_id=(club_id or CLUB_ID_DEFAULT)).first()
|
||||||
|
if club and (club.h5_domain or '').strip():
|
||||||
|
base = club.h5_domain.strip().rstrip('/')
|
||||||
|
return f'{base}/hqhd/dingdan/fubei-notify/'
|
||||||
|
|
||||||
|
|
||||||
|
def _remember_ledger(
|
||||||
|
*,
|
||||||
|
out_trade_no: str,
|
||||||
|
club_id: str,
|
||||||
|
channel: str,
|
||||||
|
channel_config_id: Optional[int],
|
||||||
|
biz_type: str,
|
||||||
|
provider_order_sn: str,
|
||||||
|
amount_yuan,
|
||||||
|
) -> None:
|
||||||
|
row = PayChannelLedger.query.filter(out_trade_no=out_trade_no).first()
|
||||||
|
fields = {
|
||||||
|
'club_id': club_id or CLUB_ID_DEFAULT,
|
||||||
|
'channel': channel,
|
||||||
|
'channel_config_id': channel_config_id,
|
||||||
|
'biz_type': biz_type or '',
|
||||||
|
'provider_order_sn': provider_order_sn or '',
|
||||||
|
'amount_yuan': Decimal(str(amount_yuan or 0)),
|
||||||
|
}
|
||||||
|
if row:
|
||||||
|
for k, v in fields.items():
|
||||||
|
setattr(row, k, v)
|
||||||
|
row.save()
|
||||||
|
else:
|
||||||
|
PayChannelLedger.objects.create(out_trade_no=out_trade_no, **fields)
|
||||||
|
|
||||||
|
|
||||||
|
def get_pay_ledger(out_trade_no: str) -> Optional[PayChannelLedger]:
|
||||||
|
if not out_trade_no:
|
||||||
|
return None
|
||||||
|
return PayChannelLedger.query.filter(out_trade_no=out_trade_no).first()
|
||||||
|
|
||||||
|
|
||||||
|
def maybe_create_fubei_jsapi_params(
|
||||||
|
*,
|
||||||
|
club_id: Optional[str],
|
||||||
|
out_trade_no: str,
|
||||||
|
amount_yuan,
|
||||||
|
body: str,
|
||||||
|
openid: str,
|
||||||
|
notify_url: str = '',
|
||||||
|
biz_type: str = FUBEI_BIZ_ORDER,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""若俱乐部选用付呗则下单并返回 wx.requestPayment 参数;否则返回 None(走原微信逻辑)。"""
|
||||||
|
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||||
|
if get_club_mini_pay_channel(cid) != MINI_PAY_CHANNEL_FUBEI:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cfg = get_fubei_config(cid)
|
||||||
|
if not cfg or not cfg.app_id or not cfg.api_key:
|
||||||
|
raise Exception('俱乐部已切换付呗收款,但付呗开放平台 ID/密钥未配置完整')
|
||||||
|
store_id = cfg.store_id
|
||||||
|
if store_id in (None, '', 0, '0'):
|
||||||
|
raise Exception('俱乐部已切换付呗收款,但门店 store_id 未配置')
|
||||||
|
if not openid:
|
||||||
|
raise Exception('付呗支付缺少用户 openid')
|
||||||
|
|
||||||
|
cb = (notify_url or '').strip() or (cfg.notify_url or '').strip() or _default_fubei_notify_url(cid)
|
||||||
|
# 强制走付呗专用回调(原微信 XML 回调无法解析付呗 form)
|
||||||
|
if 'fubei-notify' not in cb:
|
||||||
|
cb = _default_fubei_notify_url(cid)
|
||||||
|
|
||||||
|
attach = f'biz={biz_type or FUBEI_BIZ_ORDER}'
|
||||||
|
data = fubei_client.mina_prepay(
|
||||||
|
gateway_url=cfg.gateway_url,
|
||||||
|
app_id=cfg.app_id,
|
||||||
|
app_secret=cfg.api_key,
|
||||||
|
merchant_order_sn=out_trade_no,
|
||||||
|
sub_openid=openid,
|
||||||
|
total_fee=amount_yuan,
|
||||||
|
store_id=int(store_id),
|
||||||
|
call_back_url=cb,
|
||||||
|
body=body or '',
|
||||||
|
attach=attach,
|
||||||
|
)
|
||||||
|
sign_params = data.get('sign_params') or {}
|
||||||
|
if isinstance(sign_params, str):
|
||||||
|
import json
|
||||||
|
sign_params = json.loads(sign_params)
|
||||||
|
pay_params = fubei_client.extract_jsapi_pay_params(sign_params)
|
||||||
|
|
||||||
|
_remember_ledger(
|
||||||
|
out_trade_no=out_trade_no,
|
||||||
|
club_id=cid,
|
||||||
|
channel=PAY_CHANNEL_FUBEI_WX_MINI,
|
||||||
|
channel_config_id=cfg.config_id,
|
||||||
|
biz_type=biz_type or FUBEI_BIZ_ORDER,
|
||||||
|
provider_order_sn=str(data.get('order_sn') or ''),
|
||||||
|
amount_yuan=amount_yuan,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
'[FUBEI_PAY] club=%s out_trade_no=%s biz=%s order_sn=%s',
|
||||||
|
cid, out_trade_no, biz_type, data.get('order_sn'),
|
||||||
|
)
|
||||||
|
return pay_params
|
||||||
@@ -27,6 +27,8 @@ MINIAPP_ICON_META = [
|
|||||||
('fighter_recharge_member', '打手中心-会员充值横幅(左侧)'),
|
('fighter_recharge_member', '打手中心-会员充值横幅(左侧)'),
|
||||||
('fighter_recharge_deposit', '打手中心-保证金充值横幅(右侧)'),
|
('fighter_recharge_deposit', '打手中心-保证金充值横幅(右侧)'),
|
||||||
('fighter_recharge_vip_bg', '打手会员充值页-顶部背景图'),
|
('fighter_recharge_vip_bg', '打手会员充值页-顶部背景图'),
|
||||||
|
('fighter_mine_vip_bg', '打手「我的」-VIP条背景图'),
|
||||||
|
('fighter_mine_vip_icon', '打手「我的」-VIP条左侧图标'),
|
||||||
('fighter_deposit_bg', '打手保证金页-顶部背景图'),
|
('fighter_deposit_bg', '打手保证金页-顶部背景图'),
|
||||||
('fighter_deposit_hero', '打手保证金页-卡片右侧装饰图'),
|
('fighter_deposit_hero', '打手保证金页-卡片右侧装饰图'),
|
||||||
('fighter_deposit_benefit_1', '保证金页-权益图标1'),
|
('fighter_deposit_benefit_1', '保证金页-权益图标1'),
|
||||||
@@ -53,12 +55,14 @@ MINIAPP_ICON_META = [
|
|||||||
('fighter_invite_poster', '打手中心-推广海报'),
|
('fighter_invite_poster', '打手中心-推广海报'),
|
||||||
('fighter_invite_code', '打手中心-邀请码'),
|
('fighter_invite_code', '打手中心-邀请码'),
|
||||||
('fighter_kuaishou_cash', '打手中心-快手提现'),
|
('fighter_kuaishou_cash', '打手中心-快手提现'),
|
||||||
|
('fighter_follow_kuaishou', '打手中心-关注快手'),
|
||||||
('fighter_mine_copy', '打手中心-复制ID'),
|
('fighter_mine_copy', '打手中心-复制ID'),
|
||||||
('fighter_mine_edit', '打手中心-编辑资料'),
|
('fighter_mine_edit', '打手中心-编辑资料'),
|
||||||
('fighter_mine_qiepian', '打手中心-切片活动横幅'),
|
('fighter_mine_qiepian', '打手中心-切片活动横幅'),
|
||||||
('fighter_mine_penalty_tip', '打手中心-罚单提示图标'),
|
('fighter_mine_penalty_tip', '打手中心-罚单提示图标'),
|
||||||
('fighter_mine_kefu', '打手中心-在线客服'),
|
('fighter_mine_kefu', '打手中心-在线客服'),
|
||||||
('fighter_mine_shangjia', '打手中心-商家认证'),
|
('fighter_mine_shangjia', '打手中心-商家认证'),
|
||||||
|
('fighter_complaint_record', '打手中心-投诉记录'),
|
||||||
('fighter_mine_punish', '打手中心-我的处罚'),
|
('fighter_mine_punish', '打手中心-我的处罚'),
|
||||||
('fighter_mine_medal', '打手中心-考核金牌'),
|
('fighter_mine_medal', '打手中心-考核金牌'),
|
||||||
('fighter_mine_book', '打手中心-接单考试/规则'),
|
('fighter_mine_book', '打手中心-接单考试/规则'),
|
||||||
@@ -74,8 +78,17 @@ MINIAPP_ICON_META = [
|
|||||||
('fighter_mine_switch', '打手中心-返回点单端'),
|
('fighter_mine_switch', '打手中心-返回点单端'),
|
||||||
('mine_pindao', '频道入口'),
|
('mine_pindao', '频道入口'),
|
||||||
('icon_refresh', '通用刷新'),
|
('icon_refresh', '通用刷新'),
|
||||||
|
# 点单端「我的」服务入口(客服 / 邀请须分开可配)
|
||||||
|
('cuser_mine_kefu', '点单端我的-在线客服'),
|
||||||
|
('cuser_mine_invite', '点单端我的-邀请点单'),
|
||||||
|
('cuser_mine_invitees', '点单端我的-我推荐的人'),
|
||||||
('guanshi_poster_bg', '管事推广海报背景'),
|
('guanshi_poster_bg', '管事推广海报背景'),
|
||||||
('zuzhang_poster_bg', '组长推广海报背景'),
|
('zuzhang_poster_bg', '组长推广海报背景'),
|
||||||
|
('cuser_poster_bg', '点单端推荐官海报背景'),
|
||||||
|
# 打手接单池订单卡背景(未上传不下发,前端保留原样式)
|
||||||
|
('grab_card_pingtai_bg', '接单池-平台订单卡背景'),
|
||||||
|
('grab_card_youzhi_bg', '接单池-优质商家卡背景'),
|
||||||
|
('grab_card_shangjia_bg', '接单池-普通商家卡背景'),
|
||||||
]
|
]
|
||||||
|
|
||||||
MINIAPP_ICON_KEYS = [k for k, _ in MINIAPP_ICON_META]
|
MINIAPP_ICON_KEYS = [k for k, _ in MINIAPP_ICON_META]
|
||||||
@@ -99,6 +112,8 @@ MINIAPP_ICON_DEFAULTS['merchant_home_stat_bg'] = 'beijing/shangjiaduan/stat_card
|
|||||||
MINIAPP_ICON_DEFAULTS['fighter_recharge_member'] = f'{ICON_FOLDER}/fighter_recharge_member.png'
|
MINIAPP_ICON_DEFAULTS['fighter_recharge_member'] = f'{ICON_FOLDER}/fighter_recharge_member.png'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_recharge_deposit'] = f'{ICON_FOLDER}/fighter_recharge_deposit.png'
|
MINIAPP_ICON_DEFAULTS['fighter_recharge_deposit'] = f'{ICON_FOLDER}/fighter_recharge_deposit.png'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_recharge_vip_bg'] = f'{ICON_FOLDER}/fighter_recharge_vip_bg.png'
|
MINIAPP_ICON_DEFAULTS['fighter_recharge_vip_bg'] = f'{ICON_FOLDER}/fighter_recharge_vip_bg.png'
|
||||||
|
MINIAPP_ICON_DEFAULTS['fighter_mine_vip_bg'] = f'{ICON_FOLDER}/fighter_mine_vip_bg.png'
|
||||||
|
MINIAPP_ICON_DEFAULTS['fighter_mine_vip_icon'] = f'{ICON_FOLDER}/fighter_mine_vip_icon.png'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_deposit_bg'] = f'{ICON_FOLDER}/fighter_deposit_bg.png'
|
MINIAPP_ICON_DEFAULTS['fighter_deposit_bg'] = f'{ICON_FOLDER}/fighter_deposit_bg.png'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_deposit_hero'] = f'{ICON_FOLDER}/fighter_deposit_hero.png'
|
MINIAPP_ICON_DEFAULTS['fighter_deposit_hero'] = f'{ICON_FOLDER}/fighter_deposit_hero.png'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_order_pending'] = f'{ICON_FOLDER}/fighter_order_pending.png'
|
MINIAPP_ICON_DEFAULTS['fighter_order_pending'] = f'{ICON_FOLDER}/fighter_order_pending.png'
|
||||||
@@ -114,12 +129,14 @@ MINIAPP_ICON_DEFAULTS['fighter_invite_subordinate'] = f'{ICON_FOLDER}/fighter_in
|
|||||||
MINIAPP_ICON_DEFAULTS['fighter_invite_poster'] = f'{ICON_FOLDER}/fighter_invite_poster.png'
|
MINIAPP_ICON_DEFAULTS['fighter_invite_poster'] = f'{ICON_FOLDER}/fighter_invite_poster.png'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_invite_code'] = f'{ICON_FOLDER}/fighter_invite_code.png'
|
MINIAPP_ICON_DEFAULTS['fighter_invite_code'] = f'{ICON_FOLDER}/fighter_invite_code.png'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_kuaishou_cash'] = f'{ICON_FOLDER}/fighter_kuaishou_cash.png'
|
MINIAPP_ICON_DEFAULTS['fighter_kuaishou_cash'] = f'{ICON_FOLDER}/fighter_kuaishou_cash.png'
|
||||||
|
MINIAPP_ICON_DEFAULTS['fighter_follow_kuaishou'] = 'beijing/tubiao/grzx_guanzhualong.jpg'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_mine_copy'] = 'beijing/guanshiduan/icon-copy.png'
|
MINIAPP_ICON_DEFAULTS['fighter_mine_copy'] = 'beijing/guanshiduan/icon-copy.png'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_mine_edit'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-edit.jpg'
|
MINIAPP_ICON_DEFAULTS['fighter_mine_edit'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-edit.jpg'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_mine_qiepian'] = f'{ICON_FOLDER}/fighter_mine_qiepian.png'
|
MINIAPP_ICON_DEFAULTS['fighter_mine_qiepian'] = f'{ICON_FOLDER}/fighter_mine_qiepian.png'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_mine_penalty_tip'] = f'{ICON_FOLDER}/fighter_mine_penalty_tip.png'
|
MINIAPP_ICON_DEFAULTS['fighter_mine_penalty_tip'] = f'{ICON_FOLDER}/fighter_mine_penalty_tip.png'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_mine_kefu'] = 'beijing/tubiao/grzx_kefu.jpg'
|
MINIAPP_ICON_DEFAULTS['fighter_mine_kefu'] = 'beijing/tubiao/grzx_kefu.jpg'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_mine_shangjia'] = 'beijing/tubiao/grzx_shangjia.jpg'
|
MINIAPP_ICON_DEFAULTS['fighter_mine_shangjia'] = 'beijing/tubiao/grzx_shangjia.jpg'
|
||||||
|
MINIAPP_ICON_DEFAULTS['fighter_complaint_record'] = 'beijing/guanshiduan/icon-contact.png'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_mine_punish'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-punish.jpg'
|
MINIAPP_ICON_DEFAULTS['fighter_mine_punish'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-punish.jpg'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_mine_medal'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-medal.jpg'
|
MINIAPP_ICON_DEFAULTS['fighter_mine_medal'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-medal.jpg'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_mine_book'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-book.jpg'
|
MINIAPP_ICON_DEFAULTS['fighter_mine_book'] = 'beijing/dashouduan/dashoutubiaobeijing/icon-book.jpg'
|
||||||
@@ -134,16 +151,25 @@ MINIAPP_ICON_DEFAULTS['fighter_mine_dashou_auth'] = 'beijing/tubiao/grzx_dashou.
|
|||||||
MINIAPP_ICON_DEFAULTS['fighter_mine_clear'] = 'beijing/tubiao/grzx_qingchu.jpg'
|
MINIAPP_ICON_DEFAULTS['fighter_mine_clear'] = 'beijing/tubiao/grzx_qingchu.jpg'
|
||||||
MINIAPP_ICON_DEFAULTS['fighter_mine_switch'] = f'{ICON_FOLDER}/fighter_mine_switch.png'
|
MINIAPP_ICON_DEFAULTS['fighter_mine_switch'] = f'{ICON_FOLDER}/fighter_mine_switch.png'
|
||||||
MINIAPP_ICON_DEFAULTS['mine_pindao'] = 'beijing/tubiao/grzx_pindao.jpg'
|
MINIAPP_ICON_DEFAULTS['mine_pindao'] = 'beijing/tubiao/grzx_pindao.jpg'
|
||||||
|
MINIAPP_ICON_DEFAULTS['cuser_mine_kefu'] = 'beijing/tubiao/grzx_kefu.jpg'
|
||||||
|
# 邀请默认用邀请图标,避免与客服同图
|
||||||
|
MINIAPP_ICON_DEFAULTS['cuser_mine_invite'] = 'beijing/guanshiduan/icon-invite.png'
|
||||||
|
# 推荐的人:用下级/人员图标,勿与关注快手同图;勿用可能不存在的 chengxutubiao 占位 png
|
||||||
|
MINIAPP_ICON_DEFAULTS['cuser_mine_invitees'] = 'beijing/guanshiduan/icon-sub.png'
|
||||||
MINIAPP_ICON_DEFAULTS['guanshi_poster_bg'] = 'beijing/haibaobeijing.jpg'
|
MINIAPP_ICON_DEFAULTS['guanshi_poster_bg'] = 'beijing/haibaobeijing.jpg'
|
||||||
MINIAPP_ICON_DEFAULTS['zuzhang_poster_bg'] = 'beijing/zuzhangbeijing.jpg'
|
MINIAPP_ICON_DEFAULTS['zuzhang_poster_bg'] = 'beijing/zuzhangbeijing.jpg'
|
||||||
|
# 点单端推荐官海报:独立路径,禁止复用管事/打手背景
|
||||||
|
MINIAPP_ICON_DEFAULTS['cuser_poster_bg'] = 'beijing/haibao/cuser_poster_bg.jpg'
|
||||||
|
|
||||||
POSTER_BG_FIELDS = {
|
POSTER_BG_FIELDS = {
|
||||||
'guanshi_bg': 'guanshi_poster_bg',
|
'guanshi_bg': 'guanshi_poster_bg',
|
||||||
'zuzhang_bg': 'zuzhang_poster_bg',
|
'zuzhang_bg': 'zuzhang_poster_bg',
|
||||||
|
'cuser_bg': 'cuser_poster_bg',
|
||||||
}
|
}
|
||||||
POSTER_BG_DEFAULTS = {
|
POSTER_BG_DEFAULTS = {
|
||||||
'guanshi_bg': 'beijing/haibaobeijing.jpg',
|
'guanshi_bg': 'beijing/haibaobeijing.jpg',
|
||||||
'zuzhang_bg': 'beijing/zuzhangbeijing.jpg',
|
'zuzhang_bg': 'beijing/zuzhangbeijing.jpg',
|
||||||
|
'cuser_bg': 'beijing/haibao/cuser_poster_bg.jpg',
|
||||||
}
|
}
|
||||||
POSTER_FOLDER = 'beijing/haibao'
|
POSTER_FOLDER = 'beijing/haibao'
|
||||||
POSTER_ICON_KEYS = frozenset(POSTER_BG_FIELDS.values())
|
POSTER_ICON_KEYS = frozenset(POSTER_BG_FIELDS.values())
|
||||||
@@ -151,8 +177,13 @@ POSTER_ICON_KEYS = frozenset(POSTER_BG_FIELDS.values())
|
|||||||
# 仅后台上传后才下发给小程序(页面大背景等)
|
# 仅后台上传后才下发给小程序(页面大背景等)
|
||||||
CONFIG_ONLY_DELIVERY_KEYS = frozenset([
|
CONFIG_ONLY_DELIVERY_KEYS = frozenset([
|
||||||
'fighter_recharge_vip_bg',
|
'fighter_recharge_vip_bg',
|
||||||
|
'fighter_mine_vip_bg',
|
||||||
|
'fighter_mine_vip_icon',
|
||||||
'fighter_deposit_bg',
|
'fighter_deposit_bg',
|
||||||
'fighter_deposit_hero',
|
'fighter_deposit_hero',
|
||||||
|
'grab_card_pingtai_bg',
|
||||||
|
'grab_card_youzhi_bg',
|
||||||
|
'grab_card_shangjia_bg',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
@@ -170,13 +201,20 @@ def get_miniapp_icons_dict(club_id: str) -> dict:
|
|||||||
result = {}
|
result = {}
|
||||||
for key, _label in MINIAPP_ICON_META:
|
for key, _label in MINIAPP_ICON_META:
|
||||||
row = rows.get(key)
|
row = rows.get(key)
|
||||||
if row and row.is_uploaded and (row.image_path or '').strip():
|
path = (row.image_path if row else '') or ''
|
||||||
result[key] = row.image_path.strip()
|
path = path.strip()
|
||||||
|
# 已上传:必须下发(含接单池卡背景 / 充值页大图)
|
||||||
|
if row and path and row.is_uploaded:
|
||||||
|
result[key] = path
|
||||||
continue
|
continue
|
||||||
if key in CONFIG_ONLY_DELIVERY_KEYS:
|
if key in CONFIG_ONLY_DELIVERY_KEYS:
|
||||||
|
# 兼容:有非默认路径但 is_uploaded 标志异常时仍下发
|
||||||
|
default_path = default_icon_path(key)
|
||||||
|
if row and path and path != default_path:
|
||||||
|
result[key] = path
|
||||||
continue
|
continue
|
||||||
if row and (row.image_path or '').strip():
|
if row and path:
|
||||||
result[key] = row.image_path.strip()
|
result[key] = path
|
||||||
else:
|
else:
|
||||||
result[key] = default_icon_path(key)
|
result[key] = default_icon_path(key)
|
||||||
return result
|
return result
|
||||||
@@ -214,6 +252,9 @@ def get_poster_payload(club_id: str) -> dict:
|
|||||||
row = rows.get(icon_key)
|
row = rows.get(icon_key)
|
||||||
if row and row.is_uploaded and (row.image_path or '').strip():
|
if row and row.is_uploaded and (row.image_path or '').strip():
|
||||||
result[field] = row.image_path.strip()
|
result[field] = row.image_path.strip()
|
||||||
|
elif field == 'cuser_bg':
|
||||||
|
# 点单端未上传时不回落到管事背景,避免错图
|
||||||
|
result[field] = (row.image_path.strip() if row and (row.image_path or '').strip() else '') or default
|
||||||
else:
|
else:
|
||||||
result[field] = default
|
result[field] = default
|
||||||
return result
|
return result
|
||||||
@@ -222,6 +263,7 @@ def get_poster_payload(club_id: str) -> dict:
|
|||||||
def build_miniapp_assets_payload(request) -> dict:
|
def build_miniapp_assets_payload(request) -> dict:
|
||||||
club_id = _display_club_id(request)
|
club_id = _display_club_id(request)
|
||||||
return {
|
return {
|
||||||
|
'club_id': club_id,
|
||||||
'icons': get_miniapp_icons_dict(club_id),
|
'icons': get_miniapp_icons_dict(club_id),
|
||||||
'pindao': get_pindao_payload(club_id),
|
'pindao': get_pindao_payload(club_id),
|
||||||
'poster': get_poster_payload(club_id),
|
'poster': get_poster_payload(club_id),
|
||||||
|
|||||||
90
jituan/services/order_grab_share.py
Normal file
90
jituan/services/order_grab_share.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""俱乐部订单单向互通抢单:grabber 可看/抢 order 俱乐部的待接单。"""
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from jituan.constants import CLUB_ID_DEFAULT
|
||||||
|
from jituan.models import Club, ClubOrderGrabLink
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_club_id(club_id):
|
||||||
|
return (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
def order_club_ids_for_grabber(grabber_club_id):
|
||||||
|
"""
|
||||||
|
打手所属俱乐部可见的订单 ClubID 集合(含本店 + 已配置可抢的对方店)。
|
||||||
|
"""
|
||||||
|
gc = normalize_club_id(grabber_club_id)
|
||||||
|
ids = {gc}
|
||||||
|
for oc in ClubOrderGrabLink.query.filter(
|
||||||
|
grabber_club_id=gc,
|
||||||
|
).values_list('order_club_id', flat=True):
|
||||||
|
oc = (oc or '').strip()
|
||||||
|
if oc:
|
||||||
|
ids.add(oc)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def order_club_q_for_grabber(grabber_club_id):
|
||||||
|
"""用于 Order 查询的 Q:本店 + 互通店;本店含默认俱乐部时兼容空 ClubID 旧单。"""
|
||||||
|
allowed = order_club_ids_for_grabber(grabber_club_id)
|
||||||
|
q = Q(ClubID__in=list(allowed))
|
||||||
|
if CLUB_ID_DEFAULT in allowed:
|
||||||
|
q |= Q(ClubID__isnull=True) | Q(ClubID='')
|
||||||
|
return q
|
||||||
|
|
||||||
|
|
||||||
|
def can_grabber_take_order_club(grabber_club_id, order_club_id):
|
||||||
|
gc = normalize_club_id(grabber_club_id)
|
||||||
|
oc = normalize_club_id(order_club_id)
|
||||||
|
if gc == oc:
|
||||||
|
return True
|
||||||
|
return ClubOrderGrabLink.query.filter(
|
||||||
|
grabber_club_id=gc, order_club_id=oc,
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def list_grab_links():
|
||||||
|
name_map = {c.club_id: c.name for c in Club.query.filter(status=1)}
|
||||||
|
rows = list(ClubOrderGrabLink.query.order_by('grabber_club_id', 'order_club_id'))
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': r.id,
|
||||||
|
'grabber_club_id': r.grabber_club_id,
|
||||||
|
'grabber_club_name': name_map.get(r.grabber_club_id, r.grabber_club_id),
|
||||||
|
'order_club_id': r.order_club_id,
|
||||||
|
'order_club_name': name_map.get(r.order_club_id, r.order_club_id),
|
||||||
|
'create_time': r.CreateTime.strftime('%Y-%m-%d %H:%M:%S') if r.CreateTime else '',
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def add_grab_link(grabber_club_id, order_club_id):
|
||||||
|
gc = (grabber_club_id or '').strip()
|
||||||
|
oc = (order_club_id or '').strip()
|
||||||
|
if not gc or not oc:
|
||||||
|
return None, '请选择抢单方与被抢订单俱乐部'
|
||||||
|
if gc == oc:
|
||||||
|
return None, '不能配置本店抢本店(本店订单默认可见)'
|
||||||
|
if not Club.query.filter(club_id=gc, status=1).exists():
|
||||||
|
return None, f'抢单方俱乐部不存在: {gc}'
|
||||||
|
if not Club.query.filter(club_id=oc, status=1).exists():
|
||||||
|
return None, f'订单方俱乐部不存在: {oc}'
|
||||||
|
if ClubOrderGrabLink.query.filter(grabber_club_id=gc, order_club_id=oc).exists():
|
||||||
|
return None, '该互通规则已存在'
|
||||||
|
row = ClubOrderGrabLink.query.create(
|
||||||
|
grabber_club_id=gc,
|
||||||
|
order_club_id=oc,
|
||||||
|
)
|
||||||
|
return {'id': row.id, 'grabber_club_id': gc, 'order_club_id': oc}, None
|
||||||
|
|
||||||
|
|
||||||
|
def delete_grab_link(link_id):
|
||||||
|
try:
|
||||||
|
link_id = int(link_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False, '无效的规则 id'
|
||||||
|
deleted, _ = ClubOrderGrabLink.objects.filter(id=link_id).delete()
|
||||||
|
if not deleted:
|
||||||
|
return False, '规则不存在'
|
||||||
|
return True, None
|
||||||
88
jituan/services/pay_confirm.py
Normal file
88
jituan/services/pay_confirm.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""统一远程支付确认:微信直连 / 付呗 / 支付宝,供复查与轮询调用。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
{'ok': True, 'channel': 'fubei'|'wechat'|'alipay', 'transaction_id': str|None, 'amount': Decimal|None}
|
||||||
|
{'ok': False, 'pending': True, 'channel': ...} # 支付中,前端应继续轮询
|
||||||
|
{'ok': False, 'pending': False, 'channel': ..., 'msg': ...} # 明确失败(少见)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_remote_payment(
|
||||||
|
out_trade_no: str,
|
||||||
|
club_id: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
prefer_amount: Any = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""按通道查官方支付状态(付呗优先于微信直连,互不串单)。"""
|
||||||
|
from jituan.services.fubei_query import fubei_success_payload
|
||||||
|
|
||||||
|
fb = fubei_success_payload(out_trade_no, club_id)
|
||||||
|
if fb is not None:
|
||||||
|
txn, total_fee = fb
|
||||||
|
if not txn and total_fee is None:
|
||||||
|
return {'ok': False, 'pending': True, 'channel': 'fubei'}
|
||||||
|
amount = total_fee if total_fee not in (None, '') else prefer_amount
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'pending': False,
|
||||||
|
'channel': 'fubei',
|
||||||
|
'transaction_id': txn,
|
||||||
|
'amount': Decimal(str(amount)) if amount not in (None, '') else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
is_alipay = cache.get(f'alipay_pay_url_{out_trade_no}') is not None
|
||||||
|
if is_alipay:
|
||||||
|
from jituan.services.alipay_pay import query_alipay_trade
|
||||||
|
trade_status = query_alipay_trade(out_trade_no, club_id)
|
||||||
|
if trade_status in ('TRADE_SUCCESS', 'TRADE_FINISHED'):
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'pending': False,
|
||||||
|
'channel': 'alipay',
|
||||||
|
'transaction_id': None,
|
||||||
|
'amount': Decimal(str(prefer_amount)) if prefer_amount not in (None, '') else None,
|
||||||
|
}
|
||||||
|
return {'ok': False, 'pending': True, 'channel': 'alipay'}
|
||||||
|
|
||||||
|
from jituan.services.member_recharge import query_wechat_v2_trade_state
|
||||||
|
trade_state, transaction_id = query_wechat_v2_trade_state(out_trade_no, club_id)
|
||||||
|
if trade_state == 'SUCCESS':
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'pending': False,
|
||||||
|
'channel': 'wechat',
|
||||||
|
'transaction_id': transaction_id,
|
||||||
|
'amount': Decimal(str(prefer_amount)) if prefer_amount not in (None, '') else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# cache 过期兜底:再试支付宝
|
||||||
|
from jituan.services.alipay_pay import query_alipay_trade
|
||||||
|
alipay_status = query_alipay_trade(out_trade_no, club_id)
|
||||||
|
if alipay_status in ('TRADE_SUCCESS', 'TRADE_FINISHED'):
|
||||||
|
logger.info('confirm_remote: cache 过期但支付宝查单成功 %s', out_trade_no)
|
||||||
|
return {
|
||||||
|
'ok': True,
|
||||||
|
'pending': False,
|
||||||
|
'channel': 'alipay',
|
||||||
|
'transaction_id': None,
|
||||||
|
'amount': Decimal(str(prefer_amount)) if prefer_amount not in (None, '') else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if trade_state in (None, 'NOTPAY', 'USERPAYING', ''):
|
||||||
|
return {'ok': False, 'pending': True, 'channel': 'wechat', 'trade_state': trade_state}
|
||||||
|
return {
|
||||||
|
'ok': False,
|
||||||
|
'pending': False,
|
||||||
|
'channel': 'wechat',
|
||||||
|
'trade_state': trade_state,
|
||||||
|
'msg': f'支付状态异常: {trade_state}',
|
||||||
|
}
|
||||||
62
jituan/services/pay_refund_router.py
Normal file
62
jituan/services/pay_refund_router.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""按支付流水选择退款通道:付呗单走付呗,其余走原微信退款逻辑。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from jituan.constants import PAY_CHANNEL_FUBEI_WX_MINI
|
||||||
|
from jituan.services.club_payment_channel import get_fubei_config
|
||||||
|
from jituan.services import fubei_client
|
||||||
|
from jituan.services.mini_pay_router import get_pay_ledger
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _refund_sn(out_trade_no: str) -> str:
|
||||||
|
ts = str(int(time.time()))
|
||||||
|
rand = ''.join(random.choices('0123456789', k=4))
|
||||||
|
# 付呗 merchant_refund_sn 最长 32
|
||||||
|
base = f'{out_trade_no}R{ts}{rand}'
|
||||||
|
return base[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def try_fubei_refund(
|
||||||
|
*,
|
||||||
|
out_trade_no: str,
|
||||||
|
amount_yuan,
|
||||||
|
club_id: Optional[str] = None,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
若该单曾走付呗,则发起付呗退款并返回 {'code': 0/非0, 'msg': ..., 'refund_id': ...};
|
||||||
|
非付呗单返回 None,由调用方继续走原微信退款。
|
||||||
|
"""
|
||||||
|
ledger = get_pay_ledger(out_trade_no)
|
||||||
|
if not ledger or ledger.channel != PAY_CHANNEL_FUBEI_WX_MINI:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cid = (club_id or ledger.club_id or '').strip()
|
||||||
|
cfg = get_fubei_config(cid)
|
||||||
|
if not cfg or not cfg.app_id or not cfg.api_key:
|
||||||
|
logger.error('[FUBEI_REFUND] 配置缺失 club=%s out_trade_no=%s', cid, out_trade_no)
|
||||||
|
return {'code': 500, 'msg': '付呗退款配置缺失,请检查俱乐部付呗通道'}
|
||||||
|
|
||||||
|
merchant_refund_sn = _refund_sn(out_trade_no)
|
||||||
|
try:
|
||||||
|
data = fubei_client.refund_order(
|
||||||
|
gateway_url=cfg.gateway_url,
|
||||||
|
app_id=cfg.app_id,
|
||||||
|
app_secret=cfg.api_key,
|
||||||
|
merchant_order_sn=out_trade_no,
|
||||||
|
merchant_refund_sn=merchant_refund_sn,
|
||||||
|
refund_money=amount_yuan,
|
||||||
|
order_sn=ledger.provider_order_sn or '',
|
||||||
|
)
|
||||||
|
refund_id = data.get('refund_sn') or merchant_refund_sn
|
||||||
|
logger.info('[FUBEI_REFUND] ok out_trade_no=%s refund=%s', out_trade_no, refund_id)
|
||||||
|
return {'code': 0, 'msg': 'success', 'refund_id': refund_id, 'raw': data}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('[FUBEI_REFUND] fail out_trade_no=%s: %s', out_trade_no, exc, exc_info=True)
|
||||||
|
return {'code': 400, 'msg': str(exc)}
|
||||||
204
jituan/services/recharge_page_copy.py
Normal file
204
jituan/services/recharge_page_copy.py
Normal file
@@ -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)
|
||||||
@@ -15,6 +15,24 @@ def club_id_for_tixian_yonghuid_safe(yonghuid):
|
|||||||
return club_id_for_tixian_yonghuid(yonghuid)
|
return club_id_for_tixian_yonghuid(yonghuid)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_payout_club_id(*, audit=None, jilu=None, yonghuid=None, user=None):
|
||||||
|
"""
|
||||||
|
打款商户归属俱乐部:优先提现单/审核单上的 club_id,再回落用户归属。
|
||||||
|
避免用户迁店后用新俱乐部商户号打旧单资金。
|
||||||
|
"""
|
||||||
|
for obj in (audit, jilu):
|
||||||
|
if obj is None:
|
||||||
|
continue
|
||||||
|
cid = (getattr(obj, 'club_id', None) or '').strip()
|
||||||
|
if cid:
|
||||||
|
return cid
|
||||||
|
if user is not None:
|
||||||
|
return get_user_club_id(user) or ''
|
||||||
|
if yonghuid:
|
||||||
|
return club_id_for_tixian_yonghuid(yonghuid) or ''
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
def filter_tixianjilu_by_request(qs, request):
|
def filter_tixianjilu_by_request(qs, request):
|
||||||
return filter_queryset_by_club(qs, request, club_field='club_id')
|
return filter_queryset_by_club(qs, request, club_field='club_id')
|
||||||
|
|
||||||
@@ -22,6 +40,12 @@ def filter_tixianjilu_by_request(qs, request):
|
|||||||
def forbid_if_tixian_out_of_scope(request, tixian_record):
|
def forbid_if_tixian_out_of_scope(request, tixian_record):
|
||||||
"""提现记录不在当前俱乐部范围时返回 Response。"""
|
"""提现记录不在当前俱乐部范围时返回 Response。"""
|
||||||
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
if resolve_club_scope(request) == DATA_SCOPE_ALL:
|
||||||
|
from jituan.services.group_finance_gate import has_group_finance_exec
|
||||||
|
if not has_group_finance_exec(request.user, request):
|
||||||
|
return Response({
|
||||||
|
'code': 403,
|
||||||
|
'msg': '跨俱乐部提现操作仅集团高管可执行,请切换到具体俱乐部',
|
||||||
|
})
|
||||||
return None
|
return None
|
||||||
club_id = resolve_club_id_from_request(request)
|
club_id = resolve_club_id_from_request(request)
|
||||||
rec_club = getattr(tixian_record, 'club_id', None) or club_id_for_tixian_yonghuid_safe(
|
rec_club = getattr(tixian_record, 'club_id', None) or club_id_for_tixian_yonghuid_safe(
|
||||||
|
|||||||
@@ -161,13 +161,22 @@ def login_or_register_by_club(code, club_id=None, app_id=None, client_ip=''):
|
|||||||
refresh = RefreshToken.for_user(user)
|
refresh = RefreshToken.for_user(user)
|
||||||
token = str(refresh.access_token)
|
token = str(refresh.access_token)
|
||||||
|
|
||||||
from merchant_ops.services.authz import staff_fields_for_login
|
staff_fields = {}
|
||||||
from users.views import WechatMiniProgramLoginView
|
order_counts = {}
|
||||||
staff_fields = staff_fields_for_login(user)
|
group_info = {}
|
||||||
|
try:
|
||||||
|
from merchant_ops.services.authz import staff_fields_for_login
|
||||||
|
staff_fields = staff_fields_for_login(user) or {}
|
||||||
|
except Exception:
|
||||||
|
logger.exception('staff_fields_for_login 失败 uid=%s', getattr(user, 'UserUID', ''))
|
||||||
|
|
||||||
login_view = WechatMiniProgramLoginView()
|
try:
|
||||||
order_counts = login_view.jisuanOrderShuliang(user.UserUID)
|
from users.views import WechatMiniProgramLoginView
|
||||||
group_info = login_view.huoquQunPeizhi()
|
login_view = WechatMiniProgramLoginView()
|
||||||
|
order_counts = login_view.jisuanOrderShuliang(user.UserUID) or {}
|
||||||
|
group_info = login_view.huoquQunPeizhi() or {}
|
||||||
|
except Exception:
|
||||||
|
logger.exception('登录附属信息失败 uid=%s', getattr(user, 'UserUID', ''))
|
||||||
|
|
||||||
data = {
|
data = {
|
||||||
'token': token,
|
'token': token,
|
||||||
@@ -189,3 +198,26 @@ def login_or_register_by_club(code, club_id=None, app_id=None, client_ip=''):
|
|||||||
**staff_fields,
|
**staff_fields,
|
||||||
}
|
}
|
||||||
return data, None
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_wx_openid_binding(code, user, club_id=None, app_id=None):
|
||||||
|
"""已登录用户:用当前小程序 code 刷新 user_wx_openid(不换 JWT)。"""
|
||||||
|
wx_data = jscode2session(code, club_id=club_id, app_id=app_id)
|
||||||
|
if not wx_data or 'openid' not in wx_data:
|
||||||
|
return None, wx_data.get('errmsg', '微信授权失败')
|
||||||
|
|
||||||
|
openid = wx_data['openid']
|
||||||
|
unionid = wx_data.get('unionid', '')
|
||||||
|
resolved_club_id = wx_data.get('club_id') or club_id or CLUB_ID_DEFAULT
|
||||||
|
|
||||||
|
ensure_wx_openid_binding(resolved_club_id, openid, user, unionid)
|
||||||
|
ensure_user_club_id(user, resolved_club_id)
|
||||||
|
if unionid and unionid != (getattr(user, 'UnionID', None) or ''):
|
||||||
|
user.UnionID = unionid
|
||||||
|
user.save(update_fields=['UnionID'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'club_id': resolved_club_id,
|
||||||
|
'openid_preview': openid[:6] + '...' + openid[-4:] if len(openid) > 10 else openid,
|
||||||
|
'wx_appid': wx_data.get('wx_appid', ''),
|
||||||
|
}, None
|
||||||
|
|||||||
@@ -1,29 +1,189 @@
|
|||||||
"""各俱乐部微信支付 V2(MD5)配置与验签。"""
|
"""各俱乐部微信支付 V2(MD5)配置与验签。"""
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from jituan.constants import CLUB_ID_DEFAULT
|
from jituan.constants import CLUB_ID_DEFAULT
|
||||||
from jituan.models import Club
|
from jituan.models import Club
|
||||||
|
from jituan.services.club_resolver import get_club_miniapp_appid
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 星阙(xq) 生产微信支付 — club 表为空时的兜底(禁止回落 settings 旧商户 1746545364)
|
||||||
|
XQ_WX_PAY = {
|
||||||
|
'mch_id': '1747846572',
|
||||||
|
'mch_key': 'dujie4637rufjd98ijuyrgcvbvbn4789',
|
||||||
|
'api_v3_key': 'dujie4637rufjd98ijuyrgcvbvbn4789',
|
||||||
|
'cert_serial_no': '481784CF56E872D9D8122BAC64E6BC7390803582',
|
||||||
|
'wx_appid': 'wx0e4be86faac4a8d1',
|
||||||
|
}
|
||||||
|
|
||||||
|
# 与星阙共用同一微信商户号/证书目录的俱乐部(AppID 各读各的 club 表)
|
||||||
|
SHARED_WX_MERCHANT_CLUBS = frozenset({CLUB_ID_DEFAULT, 'xzj'})
|
||||||
|
|
||||||
|
# 兼容旧引用
|
||||||
|
WX_V3_CERT_SERIAL_NO_OVERRIDE = XQ_WX_PAY['cert_serial_no']
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_club_field(club, cfg_json, *field_names):
|
||||||
|
"""从 club 列或 config_json 取第一个非空值。"""
|
||||||
|
if club:
|
||||||
|
for name in field_names:
|
||||||
|
if '.' not in name:
|
||||||
|
val = (getattr(club, name, None) or '').strip()
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
cfg_json = cfg_json or {}
|
||||||
|
for name in field_names:
|
||||||
|
key = name.split('.')[-1] if '.' in name else name
|
||||||
|
val = (cfg_json.get(key) or '').strip()
|
||||||
|
if val:
|
||||||
|
return val
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _load_pem_private_key(private_key_path):
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
with open(private_key_path, 'rb') as f:
|
||||||
|
return serialization.load_pem_private_key(
|
||||||
|
f.read(), password=None, backend=default_backend(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_keys_match(private_key, cert_public_key):
|
||||||
|
try:
|
||||||
|
return private_key.public_key().public_numbers() == cert_public_key.public_numbers()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_merchant_cert_serial(private_key_path, configured_serial=''):
|
||||||
|
"""
|
||||||
|
扫描私钥同目录下所有 PEM,找到与 apiclient_key.pem 公钥匹配的商户证书,
|
||||||
|
返回其序列号。手填/数据库里的序列号不可信,必须以私钥配对证书为准。
|
||||||
|
"""
|
||||||
|
configured = (configured_serial or '').strip().upper()
|
||||||
|
if not private_key_path:
|
||||||
|
logger.error('[WX_V3_CONFIG] 未配置私钥路径')
|
||||||
|
return configured
|
||||||
|
if not os.path.isfile(private_key_path):
|
||||||
|
logger.error('[WX_V3_CONFIG] 私钥文件不存在 path=%s', private_key_path)
|
||||||
|
return configured
|
||||||
|
|
||||||
|
try:
|
||||||
|
private_key = _load_pem_private_key(private_key_path)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('[WX_V3_CONFIG] 私钥加载失败 path=%s err=%s', private_key_path, exc)
|
||||||
|
return configured
|
||||||
|
|
||||||
|
cert_dir = os.path.dirname(os.path.abspath(private_key_path))
|
||||||
|
candidates = [
|
||||||
|
private_key_path.replace('apiclient_key.pem', 'apiclient_cert.pem'),
|
||||||
|
os.path.join(cert_dir, 'apiclient_cert.pem'),
|
||||||
|
os.path.join(cert_dir, 'wechatpay_cert.pem'),
|
||||||
|
os.path.join(cert_dir, 'cert.pem'),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
for fname in sorted(os.listdir(cert_dir)):
|
||||||
|
if not fname.lower().endswith('.pem'):
|
||||||
|
continue
|
||||||
|
lower = fname.lower()
|
||||||
|
# 只跳过私钥文件;pub_key.pem 等仍尝试(有人误命名证书)
|
||||||
|
if lower in ('apiclient_key.pem',) or 'private' in lower:
|
||||||
|
continue
|
||||||
|
candidates.append(os.path.join(cert_dir, fname))
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning('[WX_V3_CONFIG] 扫描证书目录失败 dir=%s err=%s', cert_dir, exc)
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
from cryptography import x509
|
||||||
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
|
||||||
|
for path in candidates:
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
if path in seen or not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
seen.add(path)
|
||||||
|
try:
|
||||||
|
if os.path.samefile(path, private_key_path):
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
if b'PRIVATE KEY' in data:
|
||||||
|
continue
|
||||||
|
cert = x509.load_pem_x509_certificate(data, default_backend())
|
||||||
|
if not _public_keys_match(private_key, cert.public_key()):
|
||||||
|
continue
|
||||||
|
serial = format(cert.serial_number, 'X').upper()
|
||||||
|
logger.warning(
|
||||||
|
'[WX_V3_CONFIG] 私钥与证书已匹配 cert=%s serial=%s (configured=%s)',
|
||||||
|
path, serial, configured or '(none)',
|
||||||
|
)
|
||||||
|
return serial
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug('[WX_V3_CONFIG] 跳过证书 path=%s err=%s', path, exc)
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
'[WX_V3_CONFIG] 未找到与私钥匹配的 apiclient_cert.pem dir=%s key=%s configured=%s',
|
||||||
|
cert_dir, private_key_path, configured,
|
||||||
|
)
|
||||||
|
return configured
|
||||||
|
|
||||||
|
|
||||||
|
def _xq_defaults():
|
||||||
|
"""仅私钥路径/平台证书目录可回落 settings(与商户号无关)。"""
|
||||||
|
wx = dict(getattr(settings, 'WECHAT_PAY_V3_CONFIG', {}) or {})
|
||||||
|
return {
|
||||||
|
'PRIVATE_KEY_PATH': wx.get('PRIVATE_KEY_PATH', ''),
|
||||||
|
'PLATFORM_CERT_DIR': wx.get('PLATFORM_CERT_DIR', ''),
|
||||||
|
'TRANSFER_SCENE_ID': wx.get('TRANSFER_SCENE_ID', '1005'),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _xml_text(root, tag, default=''):
|
||||||
|
node = root.find(tag)
|
||||||
|
if node is None or node.text is None:
|
||||||
|
return default
|
||||||
|
return node.text
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_v2_mch_key(club, cfg_json, fallback_key, club_id=None):
|
||||||
|
"""V2 JSAPI 统一下单用的商户 API 密钥(32 位 MD5 密钥,不是 api_v3_key)。"""
|
||||||
|
column_key = (getattr(club, 'mch_key', None) or '').strip() if club else ''
|
||||||
|
if column_key:
|
||||||
|
return column_key, 'club.mch_key'
|
||||||
|
cfg_json = cfg_json or {}
|
||||||
|
for field in ('mch_key', 'shanghumiyao', 'WEIXIN_SHANGHUMIYAO'):
|
||||||
|
val = (cfg_json.get(field) or '').strip()
|
||||||
|
if val:
|
||||||
|
return val, f'club.config_json.{field}'
|
||||||
|
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||||
|
if cid in SHARED_WX_MERCHANT_CLUBS and XQ_WX_PAY['mch_key']:
|
||||||
|
return XQ_WX_PAY['mch_key'], 'XQ_WX_PAY.mch_key'
|
||||||
|
fb = (fallback_key or '').strip()
|
||||||
|
if fb:
|
||||||
|
return fb, 'settings.WEIXIN_SHANGHUMIYAO'
|
||||||
|
return '', 'missing'
|
||||||
|
|
||||||
|
|
||||||
def get_wechat_v2_config(club_id=None):
|
def get_wechat_v2_config(club_id=None):
|
||||||
"""
|
"""
|
||||||
小程序 JSAPI 支付 V2 配置。
|
小程序 JSAPI 支付 V2 配置。
|
||||||
多 club 时读 club 表;缺省或 xq 回落 settings(现网不变)。
|
各俱乐部(含 xq)优先读 club 表;字段为空时按项回落 settings,兼容旧部署。
|
||||||
"""
|
"""
|
||||||
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||||
fallback = {
|
fallback = {
|
||||||
'appid': getattr(settings, 'WEIXIN_APPID', ''),
|
'appid': getattr(settings, 'WEIXIN_APPID', ''),
|
||||||
'mch_id': getattr(settings, 'WEIXIN_MCHID', ''),
|
'mch_id': getattr(settings, 'WEIXIN_MCHID', ''),
|
||||||
'key': getattr(settings, 'WEIXIN_SHANGHUMIYAO', ''),
|
'key': getattr(settings, 'WEIXIN_SHANGHUMIYAO', ''),
|
||||||
'club_id': CLUB_ID_DEFAULT,
|
'club_id': cid,
|
||||||
}
|
}
|
||||||
if cid == CLUB_ID_DEFAULT:
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
club = Club.query.get(club_id=cid)
|
club = Club.query.get(club_id=cid)
|
||||||
@@ -32,20 +192,117 @@ def get_wechat_v2_config(club_id=None):
|
|||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
cfg_json = club.config_json or {}
|
cfg_json = club.config_json or {}
|
||||||
mch_key = (
|
mch_key, key_source = _resolve_v2_mch_key(club, cfg_json, fallback['key'], club_id=cid)
|
||||||
cfg_json.get('mch_key')
|
mini_appid = get_club_miniapp_appid(club)
|
||||||
or cfg_json.get('shanghumiyao')
|
wx_a = (club.wx_appid or '').strip()
|
||||||
or cfg_json.get('WEIXIN_SHANGHUMIYAO')
|
pay_a = (club.pay_app_id or '').strip()
|
||||||
or fallback['key']
|
if wx_a and pay_a and wx_a != pay_a:
|
||||||
|
logger.warning(
|
||||||
|
'[WX_V2_CONFIG] club=%s wx_appid(%s) 与 pay_app_id(%s) 不一致;'
|
||||||
|
'小程序 JSAPI 以 wx_appid 优先(openid 与登录 AppID 绑定)',
|
||||||
|
cid, wx_a, pay_a,
|
||||||
|
)
|
||||||
|
if mini_appid:
|
||||||
|
appid = mini_appid
|
||||||
|
elif cid == CLUB_ID_DEFAULT:
|
||||||
|
appid = fallback['appid']
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
'[WX_V2_CONFIG] club=%s 未配置 wx_appid/pay_app_id,禁止回落星阙全局 AppID',
|
||||||
|
cid,
|
||||||
|
)
|
||||||
|
appid = ''
|
||||||
|
pay_fb = XQ_WX_PAY if cid in SHARED_WX_MERCHANT_CLUBS else {}
|
||||||
|
mch_id = (
|
||||||
|
_pick_club_field(club, cfg_json, 'mch_id')
|
||||||
|
or pay_fb.get('mch_id', '')
|
||||||
|
or fallback['mch_id']
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if mch_id and key_source == 'settings.WEIXIN_SHANGHUMIYAO':
|
||||||
|
logger.warning(
|
||||||
|
'[WX_V2_CONFIG] club=%s 已配置 mch_id=%s 但未配置 V2 商户密钥 config_json.mch_key;'
|
||||||
|
'当前回落全局 WEIXIN_SHANGHUMIYAO(与 api_v3_key 不是同一个密钥,易导致签名错误)',
|
||||||
|
cid, mch_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 收款商户证书(供退款等对齐收款商户;未配时留空,调用方可回落 settings)
|
||||||
|
pay_cert = _pick_club_field(club, cfg_json, 'pay_cert_path')
|
||||||
|
pay_key = _pick_club_field(club, cfg_json, 'pay_key_path')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'appid': club.pay_app_id or club.wx_appid or fallback['appid'],
|
'appid': appid,
|
||||||
'mch_id': club.mch_id or fallback['mch_id'],
|
'mch_id': mch_id,
|
||||||
'key': mch_key,
|
'key': mch_key,
|
||||||
'club_id': cid,
|
'club_id': cid,
|
||||||
|
'_key_source': key_source,
|
||||||
|
# 明确标记:这是点单/会员收款商户
|
||||||
|
'merchant_role': 'pay',
|
||||||
|
'cert_path': pay_cert,
|
||||||
|
'key_path': pay_key,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def log_wechat_v2_pay_diag(pay_cfg, *, scene='', openid='', order_hint=''):
|
||||||
|
"""下单前诊断日志(不打印完整密钥)。"""
|
||||||
|
oid = (openid or '')
|
||||||
|
oid_preview = (oid[:6] + '...' + oid[-4:]) if len(oid) > 10 else oid or '(empty)'
|
||||||
|
key = pay_cfg.get('key') or ''
|
||||||
|
logger.warning(
|
||||||
|
'[WX_V2_PAY_DIAG] scene=%s club=%s appid=%s mch_id=%s key_len=%d key_source=%s openid=%s hint=%s',
|
||||||
|
scene or '-',
|
||||||
|
pay_cfg.get('club_id'),
|
||||||
|
pay_cfg.get('appid'),
|
||||||
|
pay_cfg.get('mch_id'),
|
||||||
|
len(key),
|
||||||
|
pay_cfg.get('_key_source', '?'),
|
||||||
|
oid_preview,
|
||||||
|
order_hint or '-',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_wechat_v2_unifiedorder_response(content, *, log_prefix='WX_UNIFIED'):
|
||||||
|
"""
|
||||||
|
解析微信 V2 统一下单 XML;失败时记录完整原始响应并抛出可读异常。
|
||||||
|
成功返回 {'prepay_id': '...'}。
|
||||||
|
"""
|
||||||
|
import defusedxml.ElementTree as ET
|
||||||
|
|
||||||
|
raw = (
|
||||||
|
content.decode('utf-8', errors='replace')
|
||||||
|
if isinstance(content, (bytes, bytearray))
|
||||||
|
else str(content or '')
|
||||||
|
)
|
||||||
|
logger.warning('[%s] raw_response=%s', log_prefix, raw)
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = ET.fromstring(content)
|
||||||
|
except Exception as exc:
|
||||||
|
raise Exception(f'微信支付返回非 XML: {raw[:800]}') from exc
|
||||||
|
|
||||||
|
return_code = _xml_text(root, 'return_code')
|
||||||
|
result_code = _xml_text(root, 'result_code')
|
||||||
|
|
||||||
|
if return_code == 'SUCCESS' and result_code == 'SUCCESS':
|
||||||
|
prepay_id = _xml_text(root, 'prepay_id')
|
||||||
|
if not prepay_id:
|
||||||
|
raise Exception(f'微信支付成功但缺少 prepay_id: {raw}')
|
||||||
|
return {'prepay_id': prepay_id}
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if return_code:
|
||||||
|
parts.append(f'return_code={return_code}')
|
||||||
|
if result_code:
|
||||||
|
parts.append(f'result_code={result_code}')
|
||||||
|
for tag in ('return_msg', 'err_code', 'err_code_des'):
|
||||||
|
val = _xml_text(root, tag)
|
||||||
|
if val:
|
||||||
|
parts.append(f'{tag}={val}')
|
||||||
|
if not parts:
|
||||||
|
parts.append(f'raw={raw[:800]}')
|
||||||
|
raise Exception(f'微信支付下单失败: {", ".join(parts)}')
|
||||||
|
|
||||||
|
|
||||||
def verify_wechat_v2_signature(xml_data, club_id=None):
|
def verify_wechat_v2_signature(xml_data, club_id=None):
|
||||||
"""验证微信 V2 支付回调 MD5 签名。"""
|
"""验证微信 V2 支付回调 MD5 签名。"""
|
||||||
try:
|
try:
|
||||||
@@ -94,63 +351,102 @@ def club_id_from_order(order_id):
|
|||||||
return CLUB_ID_DEFAULT
|
return CLUB_ID_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
def _log_v3_config(cfg, *, source=''):
|
||||||
|
key_path = (cfg.get('PRIVATE_KEY_PATH') or '').strip()
|
||||||
|
logger.warning(
|
||||||
|
'[WX_V3_CONFIG] source=%s club=%s appid=%s mch=%s serial=%s '
|
||||||
|
'api_v3_key_len=%d key_path=%s key_exists=%s',
|
||||||
|
source or '-',
|
||||||
|
cfg.get('club_id'),
|
||||||
|
cfg.get('APPID'),
|
||||||
|
cfg.get('MCHID'),
|
||||||
|
cfg.get('CERT_SERIAL_NO'),
|
||||||
|
len(cfg.get('API_V3_KEY') or ''),
|
||||||
|
key_path,
|
||||||
|
bool(key_path and os.path.isfile(key_path)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_wechat_v3_config(club_id=None):
|
def get_wechat_v3_config(club_id=None):
|
||||||
"""
|
"""
|
||||||
微信 V3(转账/查单)配置。
|
微信 V3(转账/查单)配置。
|
||||||
多 club 读 club 表;缺省或 xq 回落 settings.WECHAT_PAY_V3_CONFIG。
|
优先 club 数据库 → 星阙写死兜底 → 仅私钥路径/平台证书目录回落 settings。
|
||||||
|
禁止用 settings 里的旧商户号 1746545364。
|
||||||
"""
|
"""
|
||||||
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
|
||||||
wx = dict(getattr(settings, 'WECHAT_PAY_V3_CONFIG', {}) or {})
|
path_defaults = _xq_defaults()
|
||||||
fallback = {
|
|
||||||
'APPID': wx.get('APPID', ''),
|
|
||||||
'MCHID': wx.get('MCHID', ''),
|
|
||||||
'PRIVATE_KEY_PATH': wx.get('PRIVATE_KEY_PATH', ''),
|
|
||||||
'CERT_SERIAL_NO': wx.get('CERT_SERIAL_NO', ''),
|
|
||||||
'API_V3_KEY': wx.get('API_V3_KEY', ''),
|
|
||||||
'PLATFORM_CERT_DIR': wx.get('PLATFORM_CERT_DIR', ''),
|
|
||||||
'TRANSFER_SCENE_ID': wx.get('TRANSFER_SCENE_ID', '1005'),
|
|
||||||
'club_id': CLUB_ID_DEFAULT,
|
|
||||||
}
|
|
||||||
if cid == CLUB_ID_DEFAULT:
|
|
||||||
return fallback
|
|
||||||
|
|
||||||
|
club = None
|
||||||
|
cfg_json = {}
|
||||||
try:
|
try:
|
||||||
club = Club.query.get(club_id=cid)
|
club = Club.query.get(club_id=cid)
|
||||||
|
cfg_json = club.config_json or {}
|
||||||
except Club.DoesNotExist:
|
except Club.DoesNotExist:
|
||||||
logger.warning('club %s 不存在,回落全局 V3 支付配置', cid)
|
logger.warning('club %s 不存在,使用星阙兜底或路径默认', cid)
|
||||||
return fallback
|
|
||||||
|
|
||||||
cfg_json = club.config_json or {}
|
xq = XQ_WX_PAY if cid in SHARED_WX_MERCHANT_CLUBS else {}
|
||||||
return {
|
|
||||||
'APPID': club.pay_app_id or club.wx_appid or fallback['APPID'],
|
# 提现商户号:优先 withdraw_mch_id,空则回落收款 mch_id(兼容旧数据)
|
||||||
'MCHID': club.mch_id or fallback['MCHID'],
|
mch_id = (
|
||||||
'PRIVATE_KEY_PATH': (
|
_pick_club_field(club, cfg_json, 'withdraw_mch_id')
|
||||||
club.private_key_path
|
or _pick_club_field(club, cfg_json, 'mch_id')
|
||||||
or cfg_json.get('private_key_path')
|
or xq.get('mch_id', '')
|
||||||
or fallback['PRIVATE_KEY_PATH']
|
)
|
||||||
),
|
api_v3_key = _pick_club_field(club, cfg_json, 'api_v3_key') or xq.get('api_v3_key', '')
|
||||||
'CERT_SERIAL_NO': (
|
|
||||||
club.cert_serial_no
|
# 共用商户的俱乐部:默认用 settings 私钥;仅当库中路径存在且文件在盘上才覆盖
|
||||||
or cfg_json.get('cert_serial_no')
|
# (避免旧库里曾填过、但原先被忽略的无效路径突然接管提现)
|
||||||
or fallback['CERT_SERIAL_NO']
|
# 非共享俱乐部:严禁回落星阙私钥/商户,缺配置直接空值,由上层拒绝打款
|
||||||
),
|
if cid in SHARED_WX_MERCHANT_CLUBS:
|
||||||
'API_V3_KEY': (
|
private_key_path = path_defaults['PRIVATE_KEY_PATH']
|
||||||
club.api_v3_key
|
if club:
|
||||||
or cfg_json.get('api_v3_key')
|
uploaded_key = (getattr(club, 'private_key_path', None) or '').strip()
|
||||||
or fallback['API_V3_KEY']
|
if uploaded_key and os.path.isfile(uploaded_key):
|
||||||
),
|
private_key_path = uploaded_key
|
||||||
'PLATFORM_CERT_DIR': (
|
platform_cert_dir = (
|
||||||
club.platform_cert_dir
|
_pick_club_field(club, cfg_json, 'platform_cert_dir')
|
||||||
or cfg_json.get('platform_cert_dir')
|
or path_defaults['PLATFORM_CERT_DIR']
|
||||||
or fallback['PLATFORM_CERT_DIR']
|
)
|
||||||
),
|
else:
|
||||||
'TRANSFER_SCENE_ID': (
|
private_key_path = _pick_club_field(club, cfg_json, 'private_key_path')
|
||||||
cfg_json.get('transfer_scene_id')
|
platform_cert_dir = _pick_club_field(club, cfg_json, 'platform_cert_dir')
|
||||||
or cfg_json.get('TRANSFER_SCENE_ID')
|
if not mch_id or not api_v3_key or not private_key_path:
|
||||||
or fallback['TRANSFER_SCENE_ID']
|
logger.error(
|
||||||
),
|
'[WX_V3_CONFIG] club=%s 提现商户配置不完整(mch=%s key=%s pem=%s),'
|
||||||
|
'禁止回落星阙商户,避免串号打款',
|
||||||
|
cid, bool(mch_id), bool(api_v3_key), bool(private_key_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
transfer_scene_id = (
|
||||||
|
_pick_club_field(club, cfg_json, 'transfer_scene_id', 'TRANSFER_SCENE_ID')
|
||||||
|
or path_defaults['TRANSFER_SCENE_ID']
|
||||||
|
)
|
||||||
|
|
||||||
|
appid = get_club_miniapp_appid(club) if club else ''
|
||||||
|
if not appid and cid in SHARED_WX_MERCHANT_CLUBS:
|
||||||
|
appid = xq.get('wx_appid', '') if cid == CLUB_ID_DEFAULT else ''
|
||||||
|
|
||||||
|
# 序列号必须从与私钥配对的 apiclient_cert.pem 读取,禁止盲用数据库/写死值
|
||||||
|
cert_serial = resolve_merchant_cert_serial(private_key_path, '')
|
||||||
|
if not cert_serial:
|
||||||
|
cert_serial = (
|
||||||
|
_pick_club_field(club, cfg_json, 'cert_serial_no')
|
||||||
|
or xq.get('cert_serial_no', '')
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = {
|
||||||
|
'APPID': appid,
|
||||||
|
'MCHID': mch_id,
|
||||||
|
'PRIVATE_KEY_PATH': private_key_path,
|
||||||
|
'CERT_SERIAL_NO': cert_serial,
|
||||||
|
'API_V3_KEY': api_v3_key,
|
||||||
|
'PLATFORM_CERT_DIR': platform_cert_dir,
|
||||||
|
'TRANSFER_SCENE_ID': transfer_scene_id,
|
||||||
'club_id': cid,
|
'club_id': cid,
|
||||||
|
'merchant_role': 'withdraw',
|
||||||
}
|
}
|
||||||
|
_log_v3_config(cfg, source='club_db' if club else 'xq_fallback')
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def club_id_for_tixian_yonghuid(yonghuid):
|
def club_id_for_tixian_yonghuid(yonghuid):
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from backend.view import (
|
|||||||
KhgglView,
|
KhgglView,
|
||||||
KefuGetDashouListView,
|
KefuGetDashouListView,
|
||||||
KefuGetShangjiaListView,
|
KefuGetShangjiaListView,
|
||||||
|
KefuAddShangjiaView,
|
||||||
PopupNoticeListAPIView,
|
PopupNoticeListAPIView,
|
||||||
PopupNoticeModifyAPIView,
|
PopupNoticeModifyAPIView,
|
||||||
ShgxgsjView,
|
ShgxgsjView,
|
||||||
@@ -41,6 +42,7 @@ from jituan.views import (
|
|||||||
ClubAdminAssignmentListView,
|
ClubAdminAssignmentListView,
|
||||||
ClubAuditLogListView,
|
ClubAuditLogListView,
|
||||||
ClubManageView,
|
ClubManageView,
|
||||||
|
ClubCertUploadView,
|
||||||
ClubCaiwuView,
|
ClubCaiwuView,
|
||||||
ClubChongzhiFinanceView,
|
ClubChongzhiFinanceView,
|
||||||
ClubDashouRegisterView,
|
ClubDashouRegisterView,
|
||||||
@@ -56,6 +58,7 @@ from jituan.views import (
|
|||||||
ClubSzxxView,
|
ClubSzxxView,
|
||||||
ClubUserSummaryView,
|
ClubUserSummaryView,
|
||||||
ClubWechatLoginView,
|
ClubWechatLoginView,
|
||||||
|
ClubWxBindRefreshView,
|
||||||
)
|
)
|
||||||
|
|
||||||
from jituan.views_dashou_exam import (
|
from jituan.views_dashou_exam import (
|
||||||
@@ -84,6 +87,7 @@ from jituan.views_club_catalog import (
|
|||||||
ClubShangpinLeixingListView,
|
ClubShangpinLeixingListView,
|
||||||
)
|
)
|
||||||
from jituan.views_shenhe_manage import ClubKhgglAddView
|
from jituan.views_shenhe_manage import ClubKhgglAddView
|
||||||
|
from jituan.views_payment_channel import ClubPaymentChannelManageView
|
||||||
from jituan.views_identity_tag import (
|
from jituan.views_identity_tag import (
|
||||||
IdentityTagBindListView,
|
IdentityTagBindListView,
|
||||||
IdentityTagBindView,
|
IdentityTagBindView,
|
||||||
@@ -91,15 +95,25 @@ from jituan.views_identity_tag import (
|
|||||||
IdentityTagManageView,
|
IdentityTagManageView,
|
||||||
IdentityTagMyTagsView,
|
IdentityTagMyTagsView,
|
||||||
)
|
)
|
||||||
|
from jituan.views_recharge_page_copy import (
|
||||||
|
RechargePageCopyAdminGetView,
|
||||||
|
RechargePageCopyAdminSaveView,
|
||||||
|
RechargePageCopyView,
|
||||||
|
)
|
||||||
|
from jituan.views_order_grab_share import ClubOrderGrabLinkManageView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'),
|
path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'),
|
||||||
|
path('auth/refresh-wx-bind', ClubWxBindRefreshView.as_view(), name='jituan_refresh_wx_bind'),
|
||||||
path('auth/kefu-login', ClubKefuLoginView.as_view(), name='jituan_kefu_login'),
|
path('auth/kefu-login', ClubKefuLoginView.as_view(), name='jituan_kefu_login'),
|
||||||
path('auth/me-context', ClubMeContextView.as_view(), name='jituan_me_context'),
|
path('auth/me-context', ClubMeContextView.as_view(), name='jituan_me_context'),
|
||||||
path('auth/menu-access', ClubKefuMenuAccessView.as_view(), name='jituan_menu_access'),
|
path('auth/menu-access', ClubKefuMenuAccessView.as_view(), name='jituan_menu_access'),
|
||||||
path('auth/perm-debug', ClubPermDebugView.as_view(), name='jituan_perm_debug'),
|
path('auth/perm-debug', ClubPermDebugView.as_view(), name='jituan_perm_debug'),
|
||||||
path('auth/dashou-register', ClubDashouRegisterView.as_view(), name='jituan_dashou_register'),
|
path('auth/dashou-register', ClubDashouRegisterView.as_view(), name='jituan_dashou_register'),
|
||||||
path('houtai/club-manage', ClubManageView.as_view(), name='jituan_club_manage'),
|
path('houtai/club-manage', ClubManageView.as_view(), name='jituan_club_manage'),
|
||||||
|
path('houtai/order-grab-link', ClubOrderGrabLinkManageView.as_view(), name='jituan_order_grab_link'),
|
||||||
|
path('houtai/payment-channel-manage', ClubPaymentChannelManageView.as_view(), name='jituan_payment_channel_manage'),
|
||||||
|
path('houtai/club-cert-upload', ClubCertUploadView.as_view(), name='jituan_club_cert_upload'),
|
||||||
path('houtai/admin-assignments', ClubAdminAssignmentListView.as_view(), name='jituan_admin_assignments'),
|
path('houtai/admin-assignments', ClubAdminAssignmentListView.as_view(), name='jituan_admin_assignments'),
|
||||||
path('houtai/caiwu', ClubCaiwuView.as_view(), name='jituan_caiwu'),
|
path('houtai/caiwu', ClubCaiwuView.as_view(), name='jituan_caiwu'),
|
||||||
path('houtai/szxx', ClubSzxxView.as_view(), name='jituan_szxx'),
|
path('houtai/szxx', ClubSzxxView.as_view(), name='jituan_szxx'),
|
||||||
@@ -117,10 +131,14 @@ urlpatterns = [
|
|||||||
path('houtai/kefuhqdslb', KefuGetDashouListView.as_view(), name='jituan_dashou_list'),
|
path('houtai/kefuhqdslb', KefuGetDashouListView.as_view(), name='jituan_dashou_list'),
|
||||||
path('houtai/hthqgslb', GetGuanliListView.as_view(), name='jituan_guanshi_list'),
|
path('houtai/hthqgslb', GetGuanliListView.as_view(), name='jituan_guanshi_list'),
|
||||||
path('houtai/hqsjgllb', KefuGetShangjiaListView.as_view(), name='jituan_shangjia_list'),
|
path('houtai/hqsjgllb', KefuGetShangjiaListView.as_view(), name='jituan_shangjia_list'),
|
||||||
|
path('houtai/tjsj', KefuAddShangjiaView.as_view(), name='jituan_shangjia_add'),
|
||||||
path('houtai/hthqzzlb', GetZuzhangListView.as_view(), name='jituan_zuzhang_list'),
|
path('houtai/hthqzzlb', GetZuzhangListView.as_view(), name='jituan_zuzhang_list'),
|
||||||
path('houtai/lunbo-list', ClubLunboListView.as_view(), name='jituan_lunbo_list'),
|
path('houtai/lunbo-list', ClubLunboListView.as_view(), name='jituan_lunbo_list'),
|
||||||
path('houtai/lunbo-manage', ClubLunboManageView.as_view(), name='jituan_lunbo_manage'),
|
path('houtai/lunbo-manage', ClubLunboManageView.as_view(), name='jituan_lunbo_manage'),
|
||||||
path('houtai/gonggao', ClubGonggaoView.as_view(), name='jituan_gonggao'),
|
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('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/list', IdentityTagListView.as_view(), name='jituan_identity_tag_list'),
|
||||||
path('houtai/identity-tag/manage', IdentityTagManageView.as_view(), name='jituan_identity_tag_manage'),
|
path('houtai/identity-tag/manage', IdentityTagManageView.as_view(), name='jituan_identity_tag_manage'),
|
||||||
|
|||||||
207
jituan/views.py
207
jituan/views.py
@@ -3,7 +3,7 @@ import logging
|
|||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.parsers import JSONParser
|
from rest_framework.parsers import JSONParser, MultiPartParser, FormParser
|
||||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
@@ -14,7 +14,7 @@ from jituan.constants import SUPER_ADMIN_PHONES, ADMIN_ROLE_LABELS, CLUB_ID_DEFA
|
|||||||
from jituan.models import Club, AdminAssignment
|
from jituan.models import Club, AdminAssignment
|
||||||
from jituan.services.admin_context import build_admin_club_context, is_kefu_backend_account, can_manage_admin_assignments
|
from jituan.services.admin_context import build_admin_club_context, is_kefu_backend_account, can_manage_admin_assignments
|
||||||
from jituan.services.kefu_menu import build_menu_access_payload
|
from jituan.services.kefu_menu import build_menu_access_payload
|
||||||
from jituan.services.wechat_login import login_or_register_by_club
|
from jituan.services.wechat_login import login_or_register_by_club, refresh_wx_openid_binding
|
||||||
from jituan.services.caiwu_stats import build_caiwu_payload
|
from jituan.services.caiwu_stats import build_caiwu_payload
|
||||||
from jituan.services.szxx_stats import build_szxx_payload
|
from jituan.services.szxx_stats import build_szxx_payload
|
||||||
from jituan.services.admin_assignments import (
|
from jituan.services.admin_assignments import (
|
||||||
@@ -66,12 +66,45 @@ class ClubWechatLoginView(APIView):
|
|||||||
code, club_id=club_id, app_id=app_id, client_ip=_client_ip(request),
|
code, club_id=club_id, app_id=app_id, client_ip=_client_ip(request),
|
||||||
)
|
)
|
||||||
if err:
|
if err:
|
||||||
|
# 配置缺失 / 微信侧错误:明确 400,方便小程序提示运营配 AppSecret
|
||||||
return Response({'code': 2, 'msg': f'微信登录失败: {err}', 'data': None},
|
return Response({'code': 2, 'msg': f'微信登录失败: {err}', 'data': None},
|
||||||
status=status.HTTP_400_BAD_REQUEST)
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
return Response({'code': 0, 'msg': '登录成功', 'data': data})
|
return Response({'code': 0, 'msg': '登录成功', 'data': data})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception('ClubWechatLoginView 异常')
|
logger.exception('ClubWechatLoginView 异常')
|
||||||
return Response({'code': 99, 'msg': '系统繁忙,请稍后重试', 'data': None},
|
return Response({
|
||||||
|
'code': 99,
|
||||||
|
'msg': f'登录异常: {type(e).__name__}: {e}',
|
||||||
|
'data': None,
|
||||||
|
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
class ClubWxBindRefreshView(APIView):
|
||||||
|
"""
|
||||||
|
已登录用户静默刷新当前小程序 openid 绑定(支付前 AppID/openid 对齐)。
|
||||||
|
POST /jituan/auth/refresh-wx-bind
|
||||||
|
body: { code, club_id?, app_id? }
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
code = (request.data.get('code') or '').strip()
|
||||||
|
club_id = (request.data.get('club_id') or '').strip() or None
|
||||||
|
app_id = (request.data.get('app_id') or '').strip() or None
|
||||||
|
if not code:
|
||||||
|
return Response({'code': 1, 'msg': '微信授权码不能为空', 'data': None},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
try:
|
||||||
|
data, err = refresh_wx_openid_binding(
|
||||||
|
code, request.user, club_id=club_id, app_id=app_id,
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return Response({'code': 2, 'msg': err, 'data': None},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return Response({'code': 0, 'msg': 'ok', 'data': data})
|
||||||
|
except Exception:
|
||||||
|
logger.exception('ClubWxBindRefreshView 异常')
|
||||||
|
return Response({'code': 99, 'msg': '刷新绑定失败', 'data': None},
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
@@ -421,13 +454,96 @@ class ClubManageView(APIView):
|
|||||||
parser_classes = [JSONParser]
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
_EDITABLE_FIELDS = (
|
_EDITABLE_FIELDS = (
|
||||||
'name', 'status', 'wx_appid', 'wx_secret', 'mch_id', 'pay_app_id', 'api_v3_key',
|
'name', 'status', 'wx_appid', 'wx_secret',
|
||||||
|
# 收款商户
|
||||||
|
'mch_id', 'pay_app_id', 'mch_key', 'pay_cert_path', 'pay_key_path',
|
||||||
|
# 小程序收款通道:wechat / fubei
|
||||||
|
'mini_pay_channel',
|
||||||
|
# 提现商户
|
||||||
|
'withdraw_mch_id', 'api_v3_key',
|
||||||
'cert_serial_no', 'private_key_path', 'platform_cert_dir',
|
'cert_serial_no', 'private_key_path', 'platform_cert_dir',
|
||||||
'official_appid', 'official_secret', 'official_token', 'encoding_aes_key',
|
'official_appid', 'official_secret', 'official_token', 'encoding_aes_key',
|
||||||
'template_id', 'template_max_per_minute', 'h5_domain', 'oss_prefix',
|
'template_id', 'template_max_per_minute', 'h5_domain', 'oss_prefix',
|
||||||
'goeasy_appkey', 'goeasy_secret', 'sort_order',
|
'goeasy_appkey', 'goeasy_secret', 'sort_order',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _effective_mch_key(club):
|
||||||
|
cfg_json = club.config_json or {}
|
||||||
|
return (
|
||||||
|
(club.mch_key or '').strip()
|
||||||
|
or (cfg_json.get('mch_key') or '').strip()
|
||||||
|
or (cfg_json.get('shanghumiyao') or '').strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sync_mch_key_to_config_json(club, mch_key):
|
||||||
|
cfg = dict(club.config_json or {})
|
||||||
|
cfg['mch_key'] = (mch_key or '').strip()
|
||||||
|
club.config_json = cfg
|
||||||
|
|
||||||
|
def _pay_config_meta(self, club):
|
||||||
|
"""明确区分:小程序 V2 支付密钥 vs APIv3 密钥,避免后台误导。"""
|
||||||
|
from jituan.services.wechat_pay import get_wechat_v2_config
|
||||||
|
|
||||||
|
cfg_json = club.config_json or {}
|
||||||
|
mch_key = self._effective_mch_key(club)
|
||||||
|
api_v3 = (club.api_v3_key or '').strip()
|
||||||
|
v2_cfg = get_wechat_v2_config(club.club_id)
|
||||||
|
|
||||||
|
warnings = []
|
||||||
|
if not mch_key:
|
||||||
|
warnings.append(
|
||||||
|
'未配置收款 APIv2 密钥 mch_key:点单/会员等收款不会使用提现 api_v3_key。'
|
||||||
|
)
|
||||||
|
if mch_key and api_v3 and mch_key == api_v3:
|
||||||
|
warnings.append(
|
||||||
|
'收款 mch_key 与提现 api_v3_key 相同:通常不正确,收款与提现是两套密钥。'
|
||||||
|
)
|
||||||
|
withdraw_mch = (getattr(club, 'withdraw_mch_id', None) or '').strip()
|
||||||
|
pay_mch = (club.mch_id or '').strip()
|
||||||
|
if withdraw_mch and pay_mch and withdraw_mch != pay_mch:
|
||||||
|
# 这是预期场景,给信息而非警告
|
||||||
|
pass
|
||||||
|
elif not withdraw_mch and pay_mch:
|
||||||
|
warnings.append(
|
||||||
|
'提现商户号未单独填写:提现将回落收款商户号 mch_id。若两套商户不同请填写 withdraw_mch_id。'
|
||||||
|
)
|
||||||
|
wx_a = (club.wx_appid or '').strip()
|
||||||
|
pay_a = (club.pay_app_id or '').strip()
|
||||||
|
if wx_a and pay_a and wx_a != pay_a:
|
||||||
|
warnings.append(
|
||||||
|
f'wx_appid({wx_a}) 与 pay_app_id({pay_a}) 不一致:'
|
||||||
|
'小程序登录/支付 openid 与 wx_appid 绑定,请保持一致或留空 pay_app_id。'
|
||||||
|
)
|
||||||
|
if not wx_a and pay_a:
|
||||||
|
warnings.append(
|
||||||
|
f'wx_appid 为空但 pay_app_id={pay_a}:登录仍可能回落旧全局 AppID,'
|
||||||
|
'导致 openid 与支付 AppID 不匹配,请填写 wx_appid。'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'mch_key': mch_key,
|
||||||
|
'mch_key_label': 'APIv2 密钥 (mch_key)',
|
||||||
|
'mch_key_help': '微信商户平台 → API安全 → 设置APIv2密钥;用于会员/点单/押金/积分等 JSAPI 收款',
|
||||||
|
'mch_key_configured': bool(mch_key),
|
||||||
|
'miniapp_wechat_v2_key': mch_key,
|
||||||
|
'miniapp_wechat_v2_key_configured': bool(mch_key),
|
||||||
|
'miniapp_wechat_v2_key_label': 'APIv2 密钥 (mch_key)',
|
||||||
|
'miniapp_wechat_v2_key_help': '微信商户平台 → API安全 → 设置APIv2密钥;用于会员/点单/押金/积分等 JSAPI 收款',
|
||||||
|
'api_v3_key': api_v3,
|
||||||
|
'api_v3_key_label': 'APIv3 密钥 (api_v3_key)',
|
||||||
|
'api_v3_key_help': '微信商户平台 → API安全 → 设置APIv3密钥;仅用于自动提现/转账回调解密,不用于小程序收款',
|
||||||
|
'effective_miniapp_pay': {
|
||||||
|
'club_id': v2_cfg.get('club_id'),
|
||||||
|
'appid': v2_cfg.get('appid'),
|
||||||
|
'mch_id': v2_cfg.get('mch_id'),
|
||||||
|
'key_len': len(v2_cfg.get('key') or ''),
|
||||||
|
'key_source': v2_cfg.get('_key_source'),
|
||||||
|
},
|
||||||
|
'warnings': warnings,
|
||||||
|
}
|
||||||
|
|
||||||
def _serialize(self, club, full=False):
|
def _serialize(self, club, full=False):
|
||||||
base = {
|
base = {
|
||||||
'club_id': club.club_id,
|
'club_id': club.club_id,
|
||||||
@@ -438,14 +554,21 @@ class ClubManageView(APIView):
|
|||||||
'oss_prefix': club.oss_prefix,
|
'oss_prefix': club.oss_prefix,
|
||||||
'mch_id': club.mch_id,
|
'mch_id': club.mch_id,
|
||||||
'pay_app_id': club.pay_app_id,
|
'pay_app_id': club.pay_app_id,
|
||||||
|
'withdraw_mch_id': getattr(club, 'withdraw_mch_id', '') or '',
|
||||||
|
'mini_pay_channel': getattr(club, 'mini_pay_channel', None) or 'wechat',
|
||||||
'goeasy_appkey': club.goeasy_appkey,
|
'goeasy_appkey': club.goeasy_appkey,
|
||||||
'template_id': club.template_id,
|
'template_id': club.template_id,
|
||||||
'sort_order': club.sort_order,
|
'sort_order': club.sort_order,
|
||||||
}
|
}
|
||||||
if full:
|
if full:
|
||||||
|
mch_key = self._effective_mch_key(club)
|
||||||
base.update({
|
base.update({
|
||||||
'wx_secret': club.wx_secret,
|
'wx_secret': club.wx_secret,
|
||||||
|
'mch_key': mch_key,
|
||||||
|
'pay_cert_path': getattr(club, 'pay_cert_path', '') or '',
|
||||||
|
'pay_key_path': getattr(club, 'pay_key_path', '') or '',
|
||||||
'api_v3_key': club.api_v3_key,
|
'api_v3_key': club.api_v3_key,
|
||||||
|
'pay_config': self._pay_config_meta(club),
|
||||||
'cert_serial_no': club.cert_serial_no,
|
'cert_serial_no': club.cert_serial_no,
|
||||||
'private_key_path': club.private_key_path,
|
'private_key_path': club.private_key_path,
|
||||||
'platform_cert_dir': club.platform_cert_dir,
|
'platform_cert_dir': club.platform_cert_dir,
|
||||||
@@ -492,8 +615,19 @@ class ClubManageView(APIView):
|
|||||||
update_fields = []
|
update_fields = []
|
||||||
for field in self._EDITABLE_FIELDS:
|
for field in self._EDITABLE_FIELDS:
|
||||||
if field in request.data:
|
if field in request.data:
|
||||||
setattr(club, field, request.data[field])
|
val = request.data[field]
|
||||||
|
if field in ('mch_key', 'api_v3_key', 'wx_secret'):
|
||||||
|
val = (val or '').strip()
|
||||||
|
if field == 'mini_pay_channel':
|
||||||
|
val = (val or 'wechat').strip().lower()
|
||||||
|
if val not in ('wechat', 'fubei'):
|
||||||
|
return Response({'code': 400, 'msg': 'mini_pay_channel 仅支持 wechat/fubei'})
|
||||||
|
setattr(club, field, val)
|
||||||
update_fields.append(field)
|
update_fields.append(field)
|
||||||
|
if 'mch_key' in request.data:
|
||||||
|
self._sync_mch_key_to_config_json(club, request.data.get('mch_key'))
|
||||||
|
if 'config_json' not in update_fields:
|
||||||
|
update_fields.append('config_json')
|
||||||
if update_fields:
|
if update_fields:
|
||||||
club.save(update_fields=update_fields)
|
club.save(update_fields=update_fields)
|
||||||
return Response({
|
return Response({
|
||||||
@@ -529,6 +663,69 @@ class ClubManageView(APIView):
|
|||||||
return Response({'code': 99, 'msg': '俱乐部配置操作失败'}, status=500)
|
return Response({'code': 99, 'msg': '俱乐部配置操作失败'}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ClubCertUploadView(APIView):
|
||||||
|
"""
|
||||||
|
POST /jituan/houtai/club-cert-upload
|
||||||
|
multipart: phone, club_id, kind, file
|
||||||
|
kind: pay_key | pay_cert | withdraw_key | withdraw_cert | withdraw_platform
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [MultiPartParser, FormParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
username = (request.data.get('phone') or request.data.get('username') or '').strip()
|
||||||
|
if not username:
|
||||||
|
return Response({'code': 401, 'msg': '缺少账号'}, status=401)
|
||||||
|
kefu_obj, permissions = verify_kefu_permission(request, username)
|
||||||
|
if kefu_obj is None:
|
||||||
|
return permissions
|
||||||
|
if not can_manage_admin_assignments(request.user):
|
||||||
|
return Response({'code': 403, 'msg': '仅集团超管可上传商户证书'}, status=403)
|
||||||
|
|
||||||
|
club_id = (request.data.get('club_id') or '').strip()
|
||||||
|
kind = (request.data.get('kind') or '').strip()
|
||||||
|
uploaded = request.FILES.get('file')
|
||||||
|
if not club_id or not kind or not uploaded:
|
||||||
|
return Response({'code': 400, 'msg': '需要 club_id、kind、file'})
|
||||||
|
|
||||||
|
club = Club.query.filter(club_id=club_id).first()
|
||||||
|
if not club:
|
||||||
|
return Response({'code': 404, 'msg': f'俱乐部 {club_id} 不存在'})
|
||||||
|
|
||||||
|
from jituan.services.club_cert_upload import save_club_cert_file, CERT_KINDS
|
||||||
|
if kind not in CERT_KINDS:
|
||||||
|
return Response({
|
||||||
|
'code': 400,
|
||||||
|
'msg': f'kind 无效,支持: {", ".join(CERT_KINDS.keys())}',
|
||||||
|
})
|
||||||
|
try:
|
||||||
|
result = save_club_cert_file(club, kind, uploaded)
|
||||||
|
except ValueError as exc:
|
||||||
|
return Response({'code': 400, 'msg': str(exc)})
|
||||||
|
except OSError as exc:
|
||||||
|
logger.exception('证书落盘失败 club=%s kind=%s', club_id, kind)
|
||||||
|
return Response({'code': 500, 'msg': f'写入失败: {exc}'})
|
||||||
|
|
||||||
|
club = Club.query.filter(club_id=club_id).first()
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': '上传成功',
|
||||||
|
'data': {
|
||||||
|
**result,
|
||||||
|
'pay_cert_path': getattr(club, 'pay_cert_path', '') or '',
|
||||||
|
'pay_key_path': getattr(club, 'pay_key_path', '') or '',
|
||||||
|
'private_key_path': club.private_key_path or '',
|
||||||
|
'platform_cert_dir': club.platform_cert_dir or '',
|
||||||
|
'withdraw_mch_id': getattr(club, 'withdraw_mch_id', '') or '',
|
||||||
|
'mch_id': club.mch_id or '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
logger.exception('ClubCertUploadView 异常')
|
||||||
|
return Response({'code': 99, 'msg': '证书上传失败'}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class ClubCaiwuView(APIView):
|
class ClubCaiwuView(APIView):
|
||||||
"""
|
"""
|
||||||
俱乐部维度财务(新接口)。
|
俱乐部维度财务(新接口)。
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ from rest_framework.permissions import IsAuthenticated
|
|||||||
|
|
||||||
from backend.view import verify_kefu_permission
|
from backend.view import verify_kefu_permission
|
||||||
|
|
||||||
|
# 小程序配置 8080a 或 商品类型专区 7007a 均可上架/下架
|
||||||
|
CLUB_LEIXING_PERMS = ('8080a', '7007a')
|
||||||
|
|
||||||
|
|
||||||
|
def _has_club_leixing_perm(permissions):
|
||||||
|
return any(p in permissions for p in CLUB_LEIXING_PERMS)
|
||||||
|
|
||||||
|
|
||||||
class ClubShangpinLeixingListView(APIView):
|
class ClubShangpinLeixingListView(APIView):
|
||||||
"""POST /jituan/houtai/club-leixing-list"""
|
"""POST /jituan/houtai/club-leixing-list"""
|
||||||
@@ -19,7 +26,7 @@ class ClubShangpinLeixingListView(APIView):
|
|||||||
kefu, permissions = verify_kefu_permission(request, phone)
|
kefu, permissions = verify_kefu_permission(request, phone)
|
||||||
if kefu is None:
|
if kefu is None:
|
||||||
return permissions
|
return permissions
|
||||||
if '8080a' not in permissions:
|
if not _has_club_leixing_perm(permissions):
|
||||||
return Response({'code': 403, 'msg': '无权限管理小程序商品类型'})
|
return Response({'code': 403, 'msg': '无权限管理小程序商品类型'})
|
||||||
from jituan.services.club_shangpin_leixing import build_club_leixing_admin_list
|
from jituan.services.club_shangpin_leixing import build_club_leixing_admin_list
|
||||||
payload = build_club_leixing_admin_list(request)
|
payload = build_club_leixing_admin_list(request)
|
||||||
@@ -38,7 +45,7 @@ class ClubShangpinLeixingCatalogView(APIView):
|
|||||||
kefu, permissions = verify_kefu_permission(request, phone)
|
kefu, permissions = verify_kefu_permission(request, phone)
|
||||||
if kefu is None:
|
if kefu is None:
|
||||||
return permissions
|
return permissions
|
||||||
if '8080a' not in permissions:
|
if not _has_club_leixing_perm(permissions):
|
||||||
return Response({'code': 403, 'msg': '无权限'})
|
return Response({'code': 403, 'msg': '无权限'})
|
||||||
from jituan.services.club_shangpin_leixing import build_club_leixing_catalog
|
from jituan.services.club_shangpin_leixing import build_club_leixing_catalog
|
||||||
return Response({'code': 0, 'data': build_club_leixing_catalog(request)})
|
return Response({'code': 0, 'data': build_club_leixing_catalog(request)})
|
||||||
@@ -57,7 +64,7 @@ class ClubShangpinLeixingEnableView(APIView):
|
|||||||
kefu, permissions = verify_kefu_permission(request, phone)
|
kefu, permissions = verify_kefu_permission(request, phone)
|
||||||
if kefu is None:
|
if kefu is None:
|
||||||
return permissions
|
return permissions
|
||||||
if '8080a' not in permissions:
|
if not _has_club_leixing_perm(permissions):
|
||||||
return Response({'code': 403, 'msg': '无权限'})
|
return Response({'code': 403, 'msg': '无权限'})
|
||||||
from jituan.services.club_shangpin_leixing import enable_leixing_for_club
|
from jituan.services.club_shangpin_leixing import enable_leixing_for_club
|
||||||
data, err = enable_leixing_for_club(request, int(leixing_id), request.data.get('paixu'))
|
data, err = enable_leixing_for_club(request, int(leixing_id), request.data.get('paixu'))
|
||||||
@@ -79,7 +86,7 @@ class ClubShangpinLeixingDisableView(APIView):
|
|||||||
kefu, permissions = verify_kefu_permission(request, phone)
|
kefu, permissions = verify_kefu_permission(request, phone)
|
||||||
if kefu is None:
|
if kefu is None:
|
||||||
return permissions
|
return permissions
|
||||||
if '8080a' not in permissions:
|
if not _has_club_leixing_perm(permissions):
|
||||||
return Response({'code': 403, 'msg': '无权限'})
|
return Response({'code': 403, 'msg': '无权限'})
|
||||||
from jituan.services.club_shangpin_leixing import disable_leixing_for_club
|
from jituan.services.club_shangpin_leixing import disable_leixing_for_club
|
||||||
data, err = disable_leixing_for_club(request, int(leixing_id))
|
data, err = disable_leixing_for_club(request, int(leixing_id))
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ from jituan.services.display_config import (
|
|||||||
get_lunbo_urls,
|
get_lunbo_urls,
|
||||||
list_response_meta,
|
list_response_meta,
|
||||||
list_tupianpeizhi_by_type,
|
list_tupianpeizhi_by_type,
|
||||||
LUNBO_PAGE_DASHOU_CENTER,
|
|
||||||
LUNBO_PAGE_OPTIONS,
|
LUNBO_PAGE_OPTIONS,
|
||||||
|
lunbo_image_type_for_page,
|
||||||
normalize_page_key,
|
normalize_page_key,
|
||||||
TUPIAN_TYPE_LABELS,
|
TUPIAN_TYPE_LABELS,
|
||||||
upsert_tupianpeizhi_single,
|
upsert_tupianpeizhi_single,
|
||||||
@@ -44,7 +44,7 @@ class ClubLunboListView(APIView):
|
|||||||
return Response({'code': 403, 'msg': '无权限管理轮播图'})
|
return Response({'code': 403, 'msg': '无权限管理轮播图'})
|
||||||
|
|
||||||
page_key = normalize_page_key(request.data.get('page_key'), image_type=1)
|
page_key = normalize_page_key(request.data.get('page_key'), image_type=1)
|
||||||
image_type = 2 if page_key == LUNBO_PAGE_DASHOU_CENTER else 1
|
image_type = lunbo_image_type_for_page(page_key)
|
||||||
urls = get_lunbo_urls(request, page_key=page_key, image_type=image_type)
|
urls = get_lunbo_urls(request, page_key=page_key, image_type=image_type)
|
||||||
return Response({
|
return Response({
|
||||||
'code': 0,
|
'code': 0,
|
||||||
@@ -79,7 +79,7 @@ class ClubLunboManageView(APIView):
|
|||||||
action = (request.data.get('action') or '').strip()
|
action = (request.data.get('action') or '').strip()
|
||||||
club_id = resolve_effective_club_id(request, request.user)
|
club_id = resolve_effective_club_id(request, request.user)
|
||||||
page_key = normalize_page_key(request.data.get('page_key'), image_type=1)
|
page_key = normalize_page_key(request.data.get('page_key'), image_type=1)
|
||||||
image_type = 2 if page_key == LUNBO_PAGE_DASHOU_CENTER else 1
|
image_type = lunbo_image_type_for_page(page_key)
|
||||||
|
|
||||||
if action == 'delete':
|
if action == 'delete':
|
||||||
tupian_url = (request.data.get('tupian_url') or '').strip()
|
tupian_url = (request.data.get('tupian_url') or '').strip()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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.display_config import forbid_display_write_in_all_scope, list_response_meta
|
||||||
from jituan.services.miniapp_assets import (
|
from jituan.services.miniapp_assets import (
|
||||||
ICON_FOLDER,
|
ICON_FOLDER,
|
||||||
|
CONFIG_ONLY_DELIVERY_KEYS,
|
||||||
MINIAPP_ICON_LABELS,
|
MINIAPP_ICON_LABELS,
|
||||||
MINIAPP_ICON_META,
|
MINIAPP_ICON_META,
|
||||||
POSTER_FOLDER,
|
POSTER_FOLDER,
|
||||||
@@ -49,13 +50,21 @@ class ClubMiniappIconListView(APIView):
|
|||||||
items = []
|
items = []
|
||||||
for key, label in MINIAPP_ICON_META:
|
for key, label in MINIAPP_ICON_META:
|
||||||
row = icons_db.get(key)
|
row = icons_db.get(key)
|
||||||
|
uploaded = bool(row and row.is_uploaded and (row.image_path or '').strip())
|
||||||
|
if uploaded:
|
||||||
|
image_path = row.image_path.strip()
|
||||||
|
elif key in CONFIG_ONLY_DELIVERY_KEYS:
|
||||||
|
# 未上传的大背景不回默认路径,避免后台预览 404、误以为已配置
|
||||||
|
image_path = ''
|
||||||
|
else:
|
||||||
|
image_path = (row.image_path if row else '') or default_icon_path(key)
|
||||||
items.append({
|
items.append({
|
||||||
'icon_key': key,
|
'icon_key': key,
|
||||||
'label': label,
|
'label': label,
|
||||||
'image_path': (row.image_path if row else '') or default_icon_path(key),
|
'image_path': image_path,
|
||||||
'description': (row.description if row else '') or '',
|
'description': (row.description if row else '') or '',
|
||||||
'is_active': row.is_active if row else True,
|
'is_active': row.is_active if row else True,
|
||||||
'is_uploaded': row.is_uploaded if row else False,
|
'is_uploaded': uploaded,
|
||||||
'id': row.id if row else None,
|
'id': row.id if row else None,
|
||||||
})
|
})
|
||||||
return Response({
|
return Response({
|
||||||
@@ -89,9 +98,20 @@ class ClubMiniappIconManageView(APIView):
|
|||||||
|
|
||||||
action = (request.data.get('action') or 'upload').strip()
|
action = (request.data.get('action') or 'upload').strip()
|
||||||
club_id = resolve_effective_club_id(request, request.user)
|
club_id = resolve_effective_club_id(request, request.user)
|
||||||
icon_key = (request.data.get('icon_key') or '').strip()
|
raw_key = request.data.get('icon_key')
|
||||||
|
if raw_key is None or raw_key == '':
|
||||||
|
raw_key = request.query_params.get('icon_key')
|
||||||
|
if isinstance(raw_key, (list, tuple)):
|
||||||
|
raw_key = raw_key[0] if raw_key else ''
|
||||||
|
icon_key = str(raw_key or '').strip()
|
||||||
if icon_key not in MINIAPP_ICON_LABELS:
|
if icon_key not in MINIAPP_ICON_LABELS:
|
||||||
return Response({'code': 400, 'msg': 'icon_key 无效'})
|
return Response({
|
||||||
|
'code': 400,
|
||||||
|
'msg': (
|
||||||
|
f'icon_key 无效: {icon_key or "(空)"}。'
|
||||||
|
'若上传接单池卡背景,请确认 Django 已 pull 到含 grab_card_* 的提交并重启进程'
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
if action == 'save_meta':
|
if action == 'save_meta':
|
||||||
description = (request.data.get('description') or '').strip()
|
description = (request.data.get('description') or '').strip()
|
||||||
@@ -118,15 +138,22 @@ class ClubMiniappIconManageView(APIView):
|
|||||||
ext = os.path.splitext(file_obj.name)[1].lower() or '.png'
|
ext = os.path.splitext(file_obj.name)[1].lower() or '.png'
|
||||||
if ext not in ('.png', '.jpg', '.jpeg', '.webp'):
|
if ext not in ('.png', '.jpg', '.jpeg', '.webp'):
|
||||||
ext = '.png'
|
ext = '.png'
|
||||||
|
import uuid
|
||||||
|
stamp = uuid.uuid4().hex[:12]
|
||||||
|
# 路径必须含 club_id + 唯一后缀:否则同名覆盖后 CDN 仍显示旧图,且会串俱乐部
|
||||||
if icon_key in POSTER_ICON_KEYS:
|
if icon_key in POSTER_ICON_KEYS:
|
||||||
oss_path = f'{POSTER_FOLDER}/{club_id}_{icon_key}{ext}'
|
oss_path = f'{POSTER_FOLDER}/{club_id}_{icon_key}_{stamp}{ext}'
|
||||||
else:
|
else:
|
||||||
oss_path = f'{ICON_FOLDER}/{icon_key}{ext}'
|
oss_path = f'{ICON_FOLDER}/{club_id}/{icon_key}_{stamp}{ext}'
|
||||||
|
|
||||||
row = ClubMiniappIcon.query.filter(club_id=club_id, icon_key=icon_key).first()
|
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:
|
old_path = (row.image_path or '').strip() if row else ''
|
||||||
delete_from_oss(row.image_path)
|
|
||||||
|
|
||||||
|
if hasattr(file_obj, 'seek'):
|
||||||
|
try:
|
||||||
|
file_obj.seek(0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if not upload_to_oss(file_obj, oss_path):
|
if not upload_to_oss(file_obj, oss_path):
|
||||||
return Response({'code': 500, 'msg': '图片上传失败'})
|
return Response({'code': 500, 'msg': '图片上传失败'})
|
||||||
|
|
||||||
@@ -145,6 +172,11 @@ class ClubMiniappIconManageView(APIView):
|
|||||||
is_active=True,
|
is_active=True,
|
||||||
is_uploaded=True,
|
is_uploaded=True,
|
||||||
)
|
)
|
||||||
|
if old_path and old_path != oss_path:
|
||||||
|
try:
|
||||||
|
delete_from_oss(old_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return Response({'code': 0, 'msg': '上传成功', 'data': {'image_path': oss_path}})
|
return Response({'code': 0, 'msg': '上传成功', 'data': {'image_path': oss_path}})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
50
jituan/views_order_grab_share.py
Normal file
50
jituan/views_order_grab_share.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""集团后台:俱乐部订单单向互通抢单配置。"""
|
||||||
|
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.admin_context import can_manage_admin_assignments
|
||||||
|
from jituan.services.order_grab_share import add_grab_link, delete_grab_link, list_grab_links
|
||||||
|
|
||||||
|
|
||||||
|
class ClubOrderGrabLinkManageView(APIView):
|
||||||
|
"""
|
||||||
|
POST /jituan/houtai/order-grab-link
|
||||||
|
action: list | add | delete
|
||||||
|
仅集团权限(can_manage_admin_assignments)可操作。
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
username = (request.data.get('username') or request.data.get('phone') or '').strip()
|
||||||
|
if not username:
|
||||||
|
return Response({'code': 400, 'msg': '缺少username'})
|
||||||
|
kefu, permissions = verify_kefu_permission(request, username)
|
||||||
|
if kefu is None:
|
||||||
|
return permissions
|
||||||
|
if not can_manage_admin_assignments(request.user, permissions):
|
||||||
|
return Response({'code': 403, 'msg': '仅集团权限可配置订单互通'})
|
||||||
|
|
||||||
|
action = (request.data.get('action') or 'list').strip()
|
||||||
|
if action == 'list':
|
||||||
|
return Response({'code': 0, 'msg': 'ok', 'data': {'list': list_grab_links()}})
|
||||||
|
|
||||||
|
if action == 'add':
|
||||||
|
data, err = add_grab_link(
|
||||||
|
request.data.get('grabber_club_id'),
|
||||||
|
request.data.get('order_club_id'),
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return Response({'code': 400, 'msg': err})
|
||||||
|
return Response({'code': 0, 'msg': '已添加', 'data': data})
|
||||||
|
|
||||||
|
if action == 'delete':
|
||||||
|
ok, err = delete_grab_link(request.data.get('id'))
|
||||||
|
if not ok:
|
||||||
|
return Response({'code': 400, 'msg': err or '删除失败'})
|
||||||
|
return Response({'code': 0, 'msg': '已删除'})
|
||||||
|
|
||||||
|
return Response({'code': 400, 'msg': '无效 action'})
|
||||||
241
jituan/views_payment_channel.py
Normal file
241
jituan/views_payment_channel.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
"""俱乐部支付通道后台管理(付呗配置 CRUD + 选用 wechat/fubei)。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
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.constants import (
|
||||||
|
CLUB_ID_DEFAULT,
|
||||||
|
MINI_PAY_CHANNEL_FUBEI,
|
||||||
|
MINI_PAY_CHANNEL_WECHAT,
|
||||||
|
PAY_CHANNEL_FUBEI_WX_MINI,
|
||||||
|
)
|
||||||
|
from jituan.models import Club, ClubPaymentChannel
|
||||||
|
from jituan.services.club_payment_channel import (
|
||||||
|
encrypt_secret_field,
|
||||||
|
invalidate_club_payment_cache,
|
||||||
|
)
|
||||||
|
from jituan.services.mini_pay_router import get_club_mini_pay_channel, set_club_mini_pay_channel
|
||||||
|
from jituan.services.admin_context import can_manage_admin_assignments
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_secret(plain: str) -> str:
|
||||||
|
s = (plain or '').strip()
|
||||||
|
if not s:
|
||||||
|
return ''
|
||||||
|
if len(s) <= 8:
|
||||||
|
return '****'
|
||||||
|
return s[:4] + '****' + s[-4:]
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_channel(row: ClubPaymentChannel, reveal_secret: bool = False) -> dict:
|
||||||
|
from jituan.services.club_payment_channel import decrypt_secret_field
|
||||||
|
secret = decrypt_secret_field(row.api_key_enc) if row.api_key_enc else ''
|
||||||
|
extra = dict(row.extra_json or {})
|
||||||
|
return {
|
||||||
|
'id': row.id,
|
||||||
|
'club_id': row.club_id,
|
||||||
|
'channel': row.channel,
|
||||||
|
'name': row.name,
|
||||||
|
'enabled': row.enabled,
|
||||||
|
'is_default': row.is_default,
|
||||||
|
'sort_order': row.sort_order,
|
||||||
|
'sandbox': row.sandbox,
|
||||||
|
'app_id': row.app_id,
|
||||||
|
'mch_id': row.mch_id,
|
||||||
|
'mini_app_id': row.mini_app_id,
|
||||||
|
'store_id': extra.get('store_id') or extra.get('fubei_store_id') or '',
|
||||||
|
'merchant_id': extra.get('merchant_id') or row.mch_id or '',
|
||||||
|
'api_key_masked': _mask_secret(secret),
|
||||||
|
'api_key': secret if reveal_secret else '',
|
||||||
|
'api_key_configured': bool(secret),
|
||||||
|
'notify_url': row.notify_url,
|
||||||
|
'gateway_url': row.gateway_url,
|
||||||
|
'remark': row.remark,
|
||||||
|
'extra_json': extra,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ClubPaymentChannelManageView(APIView):
|
||||||
|
"""POST /jituan/houtai/payment-channel-manage
|
||||||
|
|
||||||
|
actions:
|
||||||
|
- get: 当前俱乐部 mini_pay_channel + 付呗通道列表
|
||||||
|
- set_mini_pay_channel: {mini_pay_channel: wechat|fubei}
|
||||||
|
- save_fubei: 新增或更新付呗配置(按 id 或 name)
|
||||||
|
- set_default / enable / disable
|
||||||
|
- delete
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
parser_classes = [JSONParser]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
try:
|
||||||
|
username = (request.data.get('phone') or request.data.get('username') or '').strip()
|
||||||
|
if not username:
|
||||||
|
return Response({'code': 401, 'msg': '缺少账号'}, status=401)
|
||||||
|
kefu_obj, permissions = verify_kefu_permission(request, username)
|
||||||
|
if kefu_obj is None:
|
||||||
|
return permissions
|
||||||
|
if not can_manage_admin_assignments(request.user):
|
||||||
|
return Response({'code': 403, 'msg': '仅集团超管可管理支付通道'}, status=403)
|
||||||
|
|
||||||
|
action = (request.data.get('action') or 'get').strip()
|
||||||
|
club_id = (request.data.get('club_id') or '').strip() or CLUB_ID_DEFAULT
|
||||||
|
club = Club.query.filter(club_id=club_id).first()
|
||||||
|
if not club:
|
||||||
|
return Response({'code': 1, 'msg': f'俱乐部 {club_id} 不存在'})
|
||||||
|
|
||||||
|
if action == 'get':
|
||||||
|
rows = ClubPaymentChannel.query.filter(
|
||||||
|
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI,
|
||||||
|
).order_by('-is_default', 'sort_order', 'id')
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'data': {
|
||||||
|
'club_id': club_id,
|
||||||
|
'mini_pay_channel': get_club_mini_pay_channel(club_id),
|
||||||
|
'fubei_channels': [_serialize_channel(r) for r in rows],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if action == 'set_mini_pay_channel':
|
||||||
|
ch = (request.data.get('mini_pay_channel') or '').strip().lower()
|
||||||
|
try:
|
||||||
|
set_club_mini_pay_channel(club_id, ch)
|
||||||
|
except ValueError as exc:
|
||||||
|
return Response({'code': 400, 'msg': str(exc)})
|
||||||
|
if ch == MINI_PAY_CHANNEL_FUBEI:
|
||||||
|
fb = ClubPaymentChannel.query.filter(
|
||||||
|
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI, enabled=1,
|
||||||
|
).first()
|
||||||
|
if not fb:
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': '已切换为付呗,但尚未配置付呗通道,请先保存付呗配置',
|
||||||
|
'data': {'mini_pay_channel': ch},
|
||||||
|
'warning': True,
|
||||||
|
})
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': '已切换小程序收款通道',
|
||||||
|
'data': {'mini_pay_channel': ch},
|
||||||
|
})
|
||||||
|
|
||||||
|
if action == 'save_fubei':
|
||||||
|
return self._save_fubei(request, club_id)
|
||||||
|
|
||||||
|
if action in ('enable', 'disable', 'set_default', 'delete'):
|
||||||
|
row_id = request.data.get('id')
|
||||||
|
row = ClubPaymentChannel.query.filter(
|
||||||
|
id=row_id, club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI,
|
||||||
|
).first()
|
||||||
|
if not row:
|
||||||
|
return Response({'code': 1, 'msg': '配置不存在'})
|
||||||
|
if action == 'delete':
|
||||||
|
row.delete()
|
||||||
|
invalidate_club_payment_cache(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
|
||||||
|
return Response({'code': 0, 'msg': '已删除'})
|
||||||
|
if action == 'enable':
|
||||||
|
row.enabled = 1
|
||||||
|
row.save(update_fields=['enabled'])
|
||||||
|
elif action == 'disable':
|
||||||
|
row.enabled = 0
|
||||||
|
row.save(update_fields=['enabled'])
|
||||||
|
elif action == 'set_default':
|
||||||
|
ClubPaymentChannel.objects.filter(
|
||||||
|
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI, is_default=True,
|
||||||
|
).exclude(id=row.id).update(is_default=False)
|
||||||
|
row.is_default = True
|
||||||
|
row.enabled = 1
|
||||||
|
row.save(update_fields=['is_default', 'enabled'])
|
||||||
|
invalidate_club_payment_cache(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
|
||||||
|
return Response({'code': 0, 'msg': 'ok', 'data': _serialize_channel(row)})
|
||||||
|
|
||||||
|
return Response({'code': 400, 'msg': f'未知 action: {action}'})
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error('payment-channel-manage 异常: %s', exc, exc_info=True)
|
||||||
|
return Response({'code': 500, 'msg': str(exc)})
|
||||||
|
|
||||||
|
def _save_fubei(self, request, club_id: str):
|
||||||
|
name = (request.data.get('name') or '主付呗').strip() or '主付呗'
|
||||||
|
row_id = request.data.get('id')
|
||||||
|
app_id = (request.data.get('app_id') or '').strip()
|
||||||
|
api_key = request.data.get('api_key')
|
||||||
|
store_id = request.data.get('store_id')
|
||||||
|
merchant_id = (request.data.get('merchant_id') or request.data.get('mch_id') or '').strip()
|
||||||
|
mini_app_id = (request.data.get('mini_app_id') or '').strip()
|
||||||
|
remark = (request.data.get('remark') or '').strip()
|
||||||
|
|
||||||
|
if not app_id:
|
||||||
|
return Response({'code': 400, 'msg': '请填写付呗开放平台 app_id'})
|
||||||
|
if store_id in (None, ''):
|
||||||
|
return Response({'code': 400, 'msg': '请填写门店 store_id'})
|
||||||
|
|
||||||
|
row = None
|
||||||
|
if row_id:
|
||||||
|
row = ClubPaymentChannel.query.filter(
|
||||||
|
id=row_id, club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI,
|
||||||
|
).first()
|
||||||
|
if not row:
|
||||||
|
row = ClubPaymentChannel.query.filter(
|
||||||
|
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI, name=name,
|
||||||
|
).first()
|
||||||
|
creating = row is None
|
||||||
|
if creating:
|
||||||
|
row = ClubPaymentChannel(
|
||||||
|
club_id=club_id,
|
||||||
|
channel=PAY_CHANNEL_FUBEI_WX_MINI,
|
||||||
|
name=name,
|
||||||
|
enabled=1,
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
row.app_id = app_id
|
||||||
|
row.mch_id = merchant_id
|
||||||
|
row.mini_app_id = mini_app_id
|
||||||
|
row.remark = remark
|
||||||
|
if api_key is not None and str(api_key).strip():
|
||||||
|
row.api_key_enc = encrypt_secret_field(str(api_key).strip())
|
||||||
|
elif creating:
|
||||||
|
return Response({'code': 400, 'msg': '新建付呗配置必须填写接口密钥'})
|
||||||
|
|
||||||
|
extra = dict(row.extra_json or {})
|
||||||
|
try:
|
||||||
|
extra['store_id'] = int(store_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return Response({'code': 400, 'msg': 'store_id 必须是数字'})
|
||||||
|
if merchant_id:
|
||||||
|
extra['merchant_id'] = merchant_id
|
||||||
|
row.extra_json = extra
|
||||||
|
|
||||||
|
# 设为默认时取消同通道其它默认
|
||||||
|
if request.data.get('is_default', True):
|
||||||
|
ClubPaymentChannel.objects.filter(
|
||||||
|
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI, is_default=True,
|
||||||
|
).exclude(id=getattr(row, 'id', None) or 0).update(is_default=False)
|
||||||
|
row.is_default = True
|
||||||
|
row.enabled = 1
|
||||||
|
|
||||||
|
row.save()
|
||||||
|
invalidate_club_payment_cache(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
|
||||||
|
|
||||||
|
# 可选:保存后同时切到付呗
|
||||||
|
if request.data.get('activate'):
|
||||||
|
set_club_mini_pay_channel(club_id, MINI_PAY_CHANNEL_FUBEI)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'code': 0,
|
||||||
|
'msg': '付呗配置已保存',
|
||||||
|
'data': {
|
||||||
|
'channel': _serialize_channel(row),
|
||||||
|
'mini_pay_channel': get_club_mini_pay_channel(club_id),
|
||||||
|
},
|
||||||
|
})
|
||||||
138
jituan/views_recharge_page_copy.py
Normal file
138
jituan/views_recharge_page_copy.py
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -17,16 +17,20 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
def _normalize_bankuai_ids(data):
|
def _normalize_bankuai_ids(data):
|
||||||
|
"""将板块 ID 统一为整数(bankuai_id 为 AutoField)。"""
|
||||||
ids = data.get('bankuai_ids')
|
ids = data.get('bankuai_ids')
|
||||||
if ids is None and data.get('bankuai_id'):
|
if ids is None and data.get('bankuai_id') is not None:
|
||||||
ids = [data.get('bankuai_id')]
|
ids = [data.get('bankuai_id')]
|
||||||
if not isinstance(ids, list):
|
if not isinstance(ids, list):
|
||||||
return []
|
return []
|
||||||
out = []
|
out = []
|
||||||
for item in ids:
|
for item in ids:
|
||||||
s = str(item or '').strip()
|
try:
|
||||||
if s and s not in out:
|
bid = int(str(item).strip())
|
||||||
out.append(s)
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if bid > 0 and bid not in out:
|
||||||
|
out.append(bid)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,33 @@ INVITE_STATUS_EXPIRED = 3
|
|||||||
ACTOR_MERCHANT = 'MERCHANT'
|
ACTOR_MERCHANT = 'MERCHANT'
|
||||||
ACTOR_STAFF = 'STAFF'
|
ACTOR_STAFF = 'STAFF'
|
||||||
|
|
||||||
|
# 商家订单操作流水行为码(数字,同一订单可重复记录)
|
||||||
|
ACTION_SETTLE = 1
|
||||||
|
ACTION_CANCEL = 2
|
||||||
|
ACTION_REFUND_APPLY = 3
|
||||||
|
ACTION_REFUND_CANCEL = 4
|
||||||
|
ACTION_REFUND_MODIFY = 5
|
||||||
|
ACTION_PENALTY_APPLY = 6
|
||||||
|
ACTION_PENALTY_CANCEL = 7
|
||||||
|
ACTION_PENALTY_MODIFY = 8
|
||||||
|
ACTION_PENALTY_RESUBMIT = 9
|
||||||
|
ACTION_CHANGE_PLAYER = 10
|
||||||
|
ACTION_DISPATCH = 11
|
||||||
|
|
||||||
|
ORDER_ACTION_LABELS = {
|
||||||
|
ACTION_SETTLE: '结算',
|
||||||
|
ACTION_CANCEL: '撤销订单',
|
||||||
|
ACTION_REFUND_APPLY: '申请退款',
|
||||||
|
ACTION_REFUND_CANCEL: '取消退款',
|
||||||
|
ACTION_REFUND_MODIFY: '修改退款理由',
|
||||||
|
ACTION_PENALTY_APPLY: '申请罚单',
|
||||||
|
ACTION_PENALTY_CANCEL: '撤销罚单',
|
||||||
|
ACTION_PENALTY_MODIFY: '修改罚单',
|
||||||
|
ACTION_PENALTY_RESUBMIT: '重新申请罚单',
|
||||||
|
ACTION_CHANGE_PLAYER: '更换打手',
|
||||||
|
ACTION_DISPATCH: '派单',
|
||||||
|
}
|
||||||
|
|
||||||
WALLET_ALLOCATE = 'ALLOCATE'
|
WALLET_ALLOCATE = 'ALLOCATE'
|
||||||
WALLET_DISPATCH_FREEZE = 'DISPATCH_FREEZE'
|
WALLET_DISPATCH_FREEZE = 'DISPATCH_FREEZE'
|
||||||
WALLET_DISPATCH_CONFIRM = 'DISPATCH_CONFIRM'
|
WALLET_DISPATCH_CONFIRM = 'DISPATCH_CONFIRM'
|
||||||
|
|||||||
@@ -2,14 +2,63 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from backend.utils import pick_order_id
|
from backend.utils import pick_order_id
|
||||||
from merchant_ops.services.authz import StaffAuthError, member_has_perm
|
from merchant_ops.services.authz import StaffAuthError, get_active_staff_member, member_has_perm
|
||||||
from merchant_ops.models import MerchantOrderDispatch
|
from merchant_ops.models import MerchantOrderDispatch
|
||||||
from orders.models import MerchantOrderExt
|
from orders.models import MerchantOrderExt, Penalty
|
||||||
from users.business_models import User
|
from users.business_models import User
|
||||||
|
from users.models import UserShangjia
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PenaltyEvidenceAuthError(Exception):
|
||||||
|
def __init__(self, code, msg):
|
||||||
|
self.code = code
|
||||||
|
self.msg = msg
|
||||||
|
super().__init__(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_penalty_evidence_upload_auth(request, dingdan_id, fadan_id=None):
|
||||||
|
"""
|
||||||
|
商家老板或具 penalty_apply / penalty_manage 且能查看该订单的子客服。
|
||||||
|
返回所属 merchant_id(ApplicantID)。
|
||||||
|
"""
|
||||||
|
user = request.user
|
||||||
|
try:
|
||||||
|
shangjia_profile = user.ShopProfile
|
||||||
|
if shangjia_profile.zhuangtai != 1:
|
||||||
|
raise PenaltyEvidenceAuthError(403, '商家状态异常')
|
||||||
|
try:
|
||||||
|
MerchantOrderExt.query.get(Order__OrderID=dingdan_id, MerchantID=user.UserUID)
|
||||||
|
except MerchantOrderExt.DoesNotExist:
|
||||||
|
raise PenaltyEvidenceAuthError(403, '订单不存在或无权操作')
|
||||||
|
if fadan_id and not Penalty.query.filter(
|
||||||
|
id=fadan_id,
|
||||||
|
RelatedOrderID=dingdan_id,
|
||||||
|
ApplicantID=user.UserUID,
|
||||||
|
).exists():
|
||||||
|
raise PenaltyEvidenceAuthError(403, '罚单不存在或无权操作')
|
||||||
|
return user.UserUID
|
||||||
|
except UserShangjia.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
member = get_active_staff_member(user)
|
||||||
|
if not member:
|
||||||
|
raise PenaltyEvidenceAuthError(403, '非商家或子客服无权上传')
|
||||||
|
if not (member_has_perm(member, 'penalty_apply') or member_has_perm(member, 'penalty_manage')):
|
||||||
|
raise PenaltyEvidenceAuthError(403, '无罚单权限')
|
||||||
|
ok, _ = staff_can_access_order(member, dingdan_id)
|
||||||
|
if not ok:
|
||||||
|
raise PenaltyEvidenceAuthError(403, '无权操作该订单')
|
||||||
|
if fadan_id and not Penalty.query.filter(
|
||||||
|
id=fadan_id,
|
||||||
|
RelatedOrderID=dingdan_id,
|
||||||
|
ApplicantID=member.merchant_id,
|
||||||
|
).exists():
|
||||||
|
raise PenaltyEvidenceAuthError(403, '罚单不存在或无权操作')
|
||||||
|
return member.merchant_id
|
||||||
|
|
||||||
|
|
||||||
def get_merchant_user(merchant_id):
|
def get_merchant_user(merchant_id):
|
||||||
return User.query.get(UserUID=merchant_id)
|
return User.query.get(UserUID=merchant_id)
|
||||||
|
|
||||||
|
|||||||
46
merchant_ops/migrations/0003_merchantorderactionlog.py
Normal file
46
merchant_ops/migrations/0003_merchantorderactionlog.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('merchant_ops', '0002_merchantstaffmember_merchant_remark'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MerchantOrderActionLog',
|
||||||
|
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)),
|
||||||
|
('merchant_id', models.CharField(db_index=True, max_length=7)),
|
||||||
|
('order_id', models.CharField(db_index=True, max_length=32)),
|
||||||
|
('action_code', models.PositiveSmallIntegerField(db_index=True, verbose_name='行为码')),
|
||||||
|
('operator_user_id', models.CharField(db_index=True, max_length=7)),
|
||||||
|
('operator_type', models.CharField(max_length=16, verbose_name='MERCHANT/STAFF')),
|
||||||
|
('staff_member_id', models.BigIntegerField(blank=True, db_index=True, null=True)),
|
||||||
|
('role_code', models.CharField(blank=True, default='', max_length=32)),
|
||||||
|
('role_name', models.CharField(blank=True, default='', max_length=64)),
|
||||||
|
('amount', models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True)),
|
||||||
|
('leixing_id', models.IntegerField(blank=True, db_index=True, null=True)),
|
||||||
|
('remark', models.CharField(blank=True, default='', max_length=500)),
|
||||||
|
('CreateTime', models.DateTimeField(auto_now_add=True, db_index=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '商家订单操作流水',
|
||||||
|
'db_table': 'merchant_order_action_log',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='merchantorderactionlog',
|
||||||
|
index=models.Index(fields=['merchant_id', 'operator_user_id', '-CreateTime'], name='merchant_or_merchan_op_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='merchantorderactionlog',
|
||||||
|
index=models.Index(fields=['merchant_id', 'action_code', '-CreateTime'], name='merchant_or_merchan_ac_idx'),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='merchantorderactionlog',
|
||||||
|
index=models.Index(fields=['merchant_id', 'leixing_id', '-CreateTime'], name='merchant_or_merchan_lx_idx'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -223,3 +223,29 @@ class MerchantDispatchTemplate(QModel):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
db_table = 'merchant_dispatch_template'
|
db_table = 'merchant_dispatch_template'
|
||||||
|
|
||||||
|
|
||||||
|
class MerchantOrderActionLog(QModel):
|
||||||
|
"""商家侧订单操作流水:同一订单可多次记录,按真实操作人统计。"""
|
||||||
|
club_id = models.CharField(max_length=16, default='xq', db_index=True)
|
||||||
|
merchant_id = models.CharField(max_length=7, db_index=True)
|
||||||
|
order_id = models.CharField(max_length=32, db_index=True)
|
||||||
|
action_code = models.PositiveSmallIntegerField(db_index=True, verbose_name='行为码')
|
||||||
|
operator_user_id = models.CharField(max_length=7, db_index=True)
|
||||||
|
operator_type = models.CharField(max_length=16, verbose_name='MERCHANT/STAFF')
|
||||||
|
staff_member_id = models.BigIntegerField(null=True, blank=True, db_index=True)
|
||||||
|
role_code = models.CharField(max_length=32, blank=True, default='')
|
||||||
|
role_name = models.CharField(max_length=64, blank=True, default='')
|
||||||
|
amount = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
|
||||||
|
leixing_id = models.IntegerField(null=True, blank=True, db_index=True)
|
||||||
|
remark = models.CharField(max_length=500, blank=True, default='')
|
||||||
|
CreateTime = models.DateTimeField(auto_now_add=True, db_index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'merchant_order_action_log'
|
||||||
|
verbose_name = '商家订单操作流水'
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['merchant_id', 'operator_user_id', '-CreateTime']),
|
||||||
|
models.Index(fields=['merchant_id', 'action_code', '-CreateTime']),
|
||||||
|
models.Index(fields=['merchant_id', 'leixing_id', '-CreateTime']),
|
||||||
|
]
|
||||||
|
|||||||
157
merchant_ops/services/action_log.py
Normal file
157
merchant_ops/services/action_log.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
"""商家订单操作流水:真实操作人落库(支持同一订单多次操作)。"""
|
||||||
|
import logging
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
|
||||||
|
from merchant_ops.constants import (
|
||||||
|
ACTOR_MERCHANT,
|
||||||
|
ACTOR_STAFF,
|
||||||
|
ORDER_ACTION_LABELS,
|
||||||
|
)
|
||||||
|
from merchant_ops.models import MerchantOrderActionLog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_decimal(amount):
|
||||||
|
if amount is None or amount == '':
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Decimal(str(amount))
|
||||||
|
except (InvalidOperation, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip(request):
|
||||||
|
if request is None:
|
||||||
|
return ''
|
||||||
|
xff = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||||
|
if xff:
|
||||||
|
return xff.split(',')[0].strip()
|
||||||
|
return request.META.get('REMOTE_ADDR', '') or ''
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_operator_from_request(request, merchant_id):
|
||||||
|
"""
|
||||||
|
优先 request._staff_member(子客服代理);否则记为商家本人。
|
||||||
|
返回 dict: operator_user_id/operator_type/staff_member_id/role_code/role_name
|
||||||
|
"""
|
||||||
|
mid = str(merchant_id or '').strip()
|
||||||
|
staff_member = getattr(request, '_staff_member', None) if request is not None else None
|
||||||
|
if staff_member is not None:
|
||||||
|
role = getattr(staff_member, 'role', None)
|
||||||
|
return {
|
||||||
|
'operator_user_id': str(getattr(staff_member, 'staff_user_id', '') or ''),
|
||||||
|
'operator_type': ACTOR_STAFF,
|
||||||
|
'staff_member_id': getattr(staff_member, 'id', None),
|
||||||
|
'role_code': getattr(role, 'role_code', '') or '',
|
||||||
|
'role_name': getattr(role, 'role_name', '') or '',
|
||||||
|
}
|
||||||
|
# 商家本人:代理场景下 request.user 已是商家
|
||||||
|
uid = ''
|
||||||
|
if request is not None and getattr(request, 'user', None) is not None:
|
||||||
|
uid = str(getattr(request.user, 'UserUID', '') or '')
|
||||||
|
if not uid:
|
||||||
|
uid = mid
|
||||||
|
return {
|
||||||
|
'operator_user_id': uid,
|
||||||
|
'operator_type': ACTOR_MERCHANT,
|
||||||
|
'staff_member_id': None,
|
||||||
|
'role_code': 'OWNER',
|
||||||
|
'role_name': '商家本人',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def record_order_action(
|
||||||
|
*,
|
||||||
|
merchant_id,
|
||||||
|
order_id,
|
||||||
|
action_code,
|
||||||
|
operator_user_id,
|
||||||
|
operator_type,
|
||||||
|
staff_member_id=None,
|
||||||
|
role_code='',
|
||||||
|
role_name='',
|
||||||
|
amount=None,
|
||||||
|
leixing_id=None,
|
||||||
|
remark='',
|
||||||
|
club_id='xq',
|
||||||
|
request=None,
|
||||||
|
):
|
||||||
|
"""写入一条操作流水。失败只打日志,不影响主业务。"""
|
||||||
|
try:
|
||||||
|
oid = str(order_id or '').strip()
|
||||||
|
mid = str(merchant_id or '').strip()
|
||||||
|
if not oid or not mid or not operator_user_id:
|
||||||
|
logger.warning(
|
||||||
|
'操作流水跳过:参数不完整 merchant=%s order=%s action=%s operator=%s',
|
||||||
|
mid, oid, action_code, operator_user_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
code = int(action_code)
|
||||||
|
row = MerchantOrderActionLog.objects.create(
|
||||||
|
club_id=(club_id or 'xq')[:16],
|
||||||
|
merchant_id=mid[:7],
|
||||||
|
order_id=oid[:32],
|
||||||
|
action_code=code,
|
||||||
|
operator_user_id=str(operator_user_id)[:7],
|
||||||
|
operator_type=(operator_type or ACTOR_MERCHANT)[:16],
|
||||||
|
staff_member_id=staff_member_id,
|
||||||
|
role_code=(role_code or '')[:32],
|
||||||
|
role_name=(role_name or '')[:64],
|
||||||
|
amount=_to_decimal(amount),
|
||||||
|
leixing_id=int(leixing_id) if leixing_id not in (None, '') else None,
|
||||||
|
remark=(remark or '')[:500],
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
'【商家操作流水】写入成功 id=%s merchant=%s order=%s action=%s(%s) '
|
||||||
|
'operator=%s type=%s role=%s amount=%s leixing=%s ip=%s',
|
||||||
|
row.id, mid, oid, code, ORDER_ACTION_LABELS.get(code, '?'),
|
||||||
|
operator_user_id, operator_type, role_code or role_name,
|
||||||
|
amount, leixing_id, _client_ip(request),
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
except Exception:
|
||||||
|
logger.error(
|
||||||
|
'【商家操作流水】写入失败 merchant=%s order=%s action=%s',
|
||||||
|
merchant_id, order_id, action_code, exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def record_order_action_from_request(
|
||||||
|
request,
|
||||||
|
action_code,
|
||||||
|
order_id,
|
||||||
|
*,
|
||||||
|
merchant_id=None,
|
||||||
|
amount=None,
|
||||||
|
leixing_id=None,
|
||||||
|
remark='',
|
||||||
|
club_id='xq',
|
||||||
|
):
|
||||||
|
"""从 request(含 _staff_member)自动识别操作人并落库。"""
|
||||||
|
mid = str(merchant_id or '').strip()
|
||||||
|
if not mid and request is not None and getattr(request, 'user', None) is not None:
|
||||||
|
mid = str(getattr(request.user, 'UserUID', '') or '')
|
||||||
|
op = resolve_operator_from_request(request, mid)
|
||||||
|
return record_order_action(
|
||||||
|
merchant_id=mid,
|
||||||
|
order_id=order_id,
|
||||||
|
action_code=action_code,
|
||||||
|
operator_user_id=op['operator_user_id'],
|
||||||
|
operator_type=op['operator_type'],
|
||||||
|
staff_member_id=op.get('staff_member_id'),
|
||||||
|
role_code=op.get('role_code', ''),
|
||||||
|
role_name=op.get('role_name', ''),
|
||||||
|
amount=amount,
|
||||||
|
leixing_id=leixing_id,
|
||||||
|
remark=remark,
|
||||||
|
club_id=club_id,
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def leixing_id_from_order(order):
|
||||||
|
if order is None:
|
||||||
|
return None
|
||||||
|
return getattr(order, 'ProductTypeID', None) or getattr(order, 'leixing_id', None)
|
||||||
286
merchant_ops/services/action_stats.py
Normal file
286
merchant_ops/services/action_stats.py
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
"""按真实操作人聚合商家订单操作流水统计。"""
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import Count, Max, Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from merchant_ops.constants import (
|
||||||
|
ACTOR_MERCHANT,
|
||||||
|
ACTOR_STAFF,
|
||||||
|
ORDER_ACTION_LABELS,
|
||||||
|
)
|
||||||
|
from merchant_ops.models import MerchantOrderActionLog, MerchantStaffMember
|
||||||
|
from products.models import ShangpinLeixing
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_dt(value, end_of_day=False):
|
||||||
|
"""解析时间;USE_TZ=False 时必须返回 naive datetime(MySQL)。"""
|
||||||
|
if value is None or value == '':
|
||||||
|
return None
|
||||||
|
dt = None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
dt = value
|
||||||
|
else:
|
||||||
|
text = str(value).strip().replace('/', '-')
|
||||||
|
for fmt in ('%Y-%m-%d %H:%M:%S', '%Y-%m-%d %H:%M', '%Y-%m-%d'):
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(text, fmt)
|
||||||
|
if fmt == '%Y-%m-%d' and end_of_day:
|
||||||
|
dt = dt.replace(hour=23, minute=59, second=59)
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
use_tz = bool(getattr(settings, 'USE_TZ', False))
|
||||||
|
if use_tz:
|
||||||
|
if timezone.is_naive(dt):
|
||||||
|
dt = timezone.make_aware(dt, timezone.get_current_timezone())
|
||||||
|
else:
|
||||||
|
if timezone.is_aware(dt):
|
||||||
|
dt = timezone.make_naive(dt, timezone.get_current_timezone())
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _money(val):
|
||||||
|
if val is None:
|
||||||
|
return '0.00'
|
||||||
|
return f'{Decimal(str(val)):.2f}'
|
||||||
|
|
||||||
|
|
||||||
|
def _dec(val):
|
||||||
|
if val is None:
|
||||||
|
return Decimal('0')
|
||||||
|
try:
|
||||||
|
return Decimal(str(val))
|
||||||
|
except Exception:
|
||||||
|
return Decimal('0')
|
||||||
|
|
||||||
|
|
||||||
|
def build_action_stats_queryset(
|
||||||
|
merchant_id,
|
||||||
|
*,
|
||||||
|
operator_user_id=None,
|
||||||
|
operator_type=None,
|
||||||
|
role_code=None,
|
||||||
|
action_code=None,
|
||||||
|
action_codes=None,
|
||||||
|
leixing_id=None,
|
||||||
|
start_time=None,
|
||||||
|
end_time=None,
|
||||||
|
):
|
||||||
|
qs = MerchantOrderActionLog.objects.filter(merchant_id=str(merchant_id))
|
||||||
|
if operator_user_id:
|
||||||
|
qs = qs.filter(operator_user_id=str(operator_user_id))
|
||||||
|
if operator_type:
|
||||||
|
qs = qs.filter(operator_type=str(operator_type).upper())
|
||||||
|
if role_code:
|
||||||
|
qs = qs.filter(role_code=str(role_code))
|
||||||
|
if action_code not in (None, ''):
|
||||||
|
qs = qs.filter(action_code=int(action_code))
|
||||||
|
if action_codes:
|
||||||
|
codes = []
|
||||||
|
for c in action_codes:
|
||||||
|
try:
|
||||||
|
codes.append(int(c))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
if codes:
|
||||||
|
qs = qs.filter(action_code__in=codes)
|
||||||
|
if leixing_id not in (None, ''):
|
||||||
|
qs = qs.filter(leixing_id=int(leixing_id))
|
||||||
|
st = _parse_dt(start_time, end_of_day=False)
|
||||||
|
et = _parse_dt(end_time, end_of_day=True)
|
||||||
|
if st:
|
||||||
|
qs = qs.filter(CreateTime__gte=st)
|
||||||
|
if et:
|
||||||
|
qs = qs.filter(CreateTime__lte=et)
|
||||||
|
return qs
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_unique_orders(qs, group_fields):
|
||||||
|
"""
|
||||||
|
同一分组下:同一订单同一行为只计 1 单、金额取该订单该行为记录的 Max(amount)。
|
||||||
|
返回: {group_tuple: {'order_count', 'action_count', 'amount'}}
|
||||||
|
"""
|
||||||
|
# 操作次数(含重复)
|
||||||
|
action_cnt_map = {}
|
||||||
|
for row in qs.values(*group_fields).annotate(cnt=Count('id')):
|
||||||
|
key = tuple(row[f] for f in group_fields)
|
||||||
|
action_cnt_map[key] = int(row['cnt'] or 0)
|
||||||
|
|
||||||
|
# 订单去重:每个 (group..., order_id) 只留一笔金额
|
||||||
|
unique_rows = qs.values(*group_fields, 'order_id').annotate(one_amt=Max('amount'))
|
||||||
|
result = defaultdict(lambda: {
|
||||||
|
'order_count': 0,
|
||||||
|
'action_count': 0,
|
||||||
|
'amount': Decimal('0'),
|
||||||
|
})
|
||||||
|
for row in unique_rows:
|
||||||
|
key = tuple(row[f] for f in group_fields)
|
||||||
|
result[key]['order_count'] += 1
|
||||||
|
result[key]['amount'] += _dec(row['one_amt'])
|
||||||
|
|
||||||
|
for key, meta in result.items():
|
||||||
|
meta['action_count'] = action_cnt_map.get(key, 0)
|
||||||
|
# 只有重复操作、但 unique 漏掉的分组也补上(理论上 unique 覆盖全部)
|
||||||
|
for key, cnt in action_cnt_map.items():
|
||||||
|
if key not in result:
|
||||||
|
result[key] = {
|
||||||
|
'order_count': 0,
|
||||||
|
'action_count': cnt,
|
||||||
|
'amount': Decimal('0'),
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_action_logs(qs):
|
||||||
|
action_total = qs.aggregate(cnt=Count('id'))
|
||||||
|
order_total = qs.values('order_id').distinct().count()
|
||||||
|
|
||||||
|
# 总额:先按 (action_code, order_id) 去重取 Max(amount),再按订单取各行为中金额的?
|
||||||
|
# 用户关心订单量去重;总额对「全部」按 (action, order) 去重后的金额求和(不同行为各自算一笔金额)
|
||||||
|
unique_amt_rows = qs.values('action_code', 'order_id').annotate(one_amt=Max('amount'))
|
||||||
|
total_amount = sum((_dec(r['one_amt']) for r in unique_amt_rows), Decimal('0'))
|
||||||
|
|
||||||
|
by_action_map = _aggregate_unique_orders(qs, ['action_code'])
|
||||||
|
by_action = []
|
||||||
|
for key in sorted(by_action_map.keys(), key=lambda k: k[0] if k[0] is not None else 0):
|
||||||
|
code = int(key[0])
|
||||||
|
meta = by_action_map[key]
|
||||||
|
by_action.append({
|
||||||
|
'action_code': code,
|
||||||
|
'action_label': ORDER_ACTION_LABELS.get(code, f'未知({code})'),
|
||||||
|
'order_count': meta['order_count'],
|
||||||
|
'count': meta['order_count'], # 兼容旧字段:对外主展示订单量
|
||||||
|
'action_count': meta['action_count'],
|
||||||
|
'amount': _money(meta['amount']),
|
||||||
|
})
|
||||||
|
|
||||||
|
by_leixing_map = _aggregate_unique_orders(
|
||||||
|
qs.exclude(leixing_id__isnull=True),
|
||||||
|
['leixing_id'],
|
||||||
|
)
|
||||||
|
leixing_ids = [k[0] for k in by_leixing_map.keys() if k[0] is not None]
|
||||||
|
name_map = {}
|
||||||
|
if leixing_ids:
|
||||||
|
for lx in ShangpinLeixing.query.filter(id__in=leixing_ids).only('id', 'jieshao'):
|
||||||
|
name_map[lx.id] = getattr(lx, 'jieshao', '') or str(lx.id)
|
||||||
|
|
||||||
|
by_leixing = []
|
||||||
|
for key, meta in sorted(by_leixing_map.items(), key=lambda x: -x[1]['order_count']):
|
||||||
|
lid = key[0]
|
||||||
|
by_leixing.append({
|
||||||
|
'leixing_id': lid,
|
||||||
|
'leixing_name': name_map.get(lid, str(lid)),
|
||||||
|
'order_count': meta['order_count'],
|
||||||
|
'count': meta['order_count'],
|
||||||
|
'action_count': meta['action_count'],
|
||||||
|
'amount': _money(meta['amount']),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_order_count': int(order_total or 0),
|
||||||
|
'total_count': int(order_total or 0), # 主数字=去重订单量
|
||||||
|
'total_action_count': int(action_total['cnt'] or 0),
|
||||||
|
'total_amount': _money(total_amount),
|
||||||
|
'by_action': by_action,
|
||||||
|
'by_leixing': by_leixing,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_operator_action_stats(merchant_id, filters):
|
||||||
|
qs = build_action_stats_queryset(merchant_id, **filters)
|
||||||
|
summary = summarize_action_logs(qs)
|
||||||
|
|
||||||
|
people_map = _aggregate_unique_orders(
|
||||||
|
qs,
|
||||||
|
['operator_user_id', 'operator_type', 'role_code', 'role_name'],
|
||||||
|
)
|
||||||
|
|
||||||
|
# 补职位备注
|
||||||
|
staff_uids = [
|
||||||
|
k[0] for k in people_map.keys()
|
||||||
|
if k[1] == ACTOR_STAFF
|
||||||
|
]
|
||||||
|
remark_map = {}
|
||||||
|
name_map = {}
|
||||||
|
if staff_uids:
|
||||||
|
for m in MerchantStaffMember.objects.filter(
|
||||||
|
merchant_id=str(merchant_id),
|
||||||
|
staff_user_id__in=staff_uids,
|
||||||
|
status=1,
|
||||||
|
).select_related('role'):
|
||||||
|
remark_map[m.staff_user_id] = m.merchant_remark or ''
|
||||||
|
name_map[m.staff_user_id] = m.display_name or ''
|
||||||
|
|
||||||
|
people = []
|
||||||
|
for key, meta in sorted(people_map.items(), key=lambda x: -x[1]['order_count']):
|
||||||
|
uid, op_type, role_code, role_name = key
|
||||||
|
role_name = role_name or ('商家本人' if op_type == ACTOR_MERCHANT else '')
|
||||||
|
remark = remark_map.get(uid, '') if op_type == ACTOR_STAFF else ''
|
||||||
|
display = name_map.get(uid, '') if op_type == ACTOR_STAFF else '商家本人'
|
||||||
|
people.append({
|
||||||
|
'operator_user_id': uid,
|
||||||
|
'operator_type': op_type,
|
||||||
|
'role_code': role_code or '',
|
||||||
|
'role_name': role_name,
|
||||||
|
'merchant_remark': remark,
|
||||||
|
'display_name': display,
|
||||||
|
'order_count': meta['order_count'],
|
||||||
|
'count': meta['order_count'],
|
||||||
|
'action_count': meta['action_count'],
|
||||||
|
'amount': _money(meta['amount']),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
**summary,
|
||||||
|
'people': people,
|
||||||
|
'action_defs': [
|
||||||
|
{'action_code': k, 'action_label': v}
|
||||||
|
for k, v in sorted(ORDER_ACTION_LABELS.items())
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_operator_candidates(merchant_id):
|
||||||
|
"""经营数据页可选身份:商家本人 + 子客服(展示职位+备注)。"""
|
||||||
|
items = [{
|
||||||
|
'operator_user_id': str(merchant_id),
|
||||||
|
'operator_type': ACTOR_MERCHANT,
|
||||||
|
'role_code': 'OWNER',
|
||||||
|
'role_name': '商家本人',
|
||||||
|
'merchant_remark': '',
|
||||||
|
'display_name': '商家本人',
|
||||||
|
'staff_member_id': None,
|
||||||
|
}]
|
||||||
|
members = (
|
||||||
|
MerchantStaffMember.objects
|
||||||
|
.filter(merchant_id=str(merchant_id), status=1)
|
||||||
|
.select_related('role')
|
||||||
|
.order_by('id')
|
||||||
|
)
|
||||||
|
for m in members:
|
||||||
|
role_name = m.role.role_name if m.role else '子客服'
|
||||||
|
remark = (m.merchant_remark or '').strip()
|
||||||
|
nick = (m.display_name or '').strip()
|
||||||
|
parts = [role_name]
|
||||||
|
if remark:
|
||||||
|
parts.append(f'备注:{remark}')
|
||||||
|
elif nick:
|
||||||
|
parts.append(nick)
|
||||||
|
parts.append(m.staff_user_id)
|
||||||
|
items.append({
|
||||||
|
'operator_user_id': m.staff_user_id,
|
||||||
|
'operator_type': ACTOR_STAFF,
|
||||||
|
'role_code': m.role.role_code if m.role else '',
|
||||||
|
'role_name': role_name,
|
||||||
|
'merchant_remark': remark,
|
||||||
|
'display_name': ' · '.join(parts),
|
||||||
|
'staff_member_id': m.id,
|
||||||
|
})
|
||||||
|
return items
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user