From f26d35da6618e3b81d4120b0029f520928f62923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=88=BD=E5=93=92=E5=93=92?= Date: Wed, 18 Mar 2026 22:28:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9B=E8=B5=A2=E6=9C=AA=E6=9D=A5=E8=AF=84?= =?UTF-8?q?=E5=88=86=E7=B3=BB=E7=BB=9F=20-=20=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=EF=BC=88=E7=A7=BB=E9=99=A4=E5=A4=A7=E6=96=87?= =?UTF-8?q?=E4=BB=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yaml | 61 + .gitignore | 254 +++ README 2.md | 237 +++ README.md | 312 +++ backend/.env.example | 9 + backend/DEPLOY.md | 54 + backend/Dockerfile | 26 + backend/TEST_REPORT.md | 44 + backend/ai_services/__init__.py | 0 backend/ai_services/admin.py | 47 + backend/ai_services/apps.py | 5 + backend/ai_services/bailian_service.py | 323 ++++ backend/ai_services/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/check_aliyun_config.py | 54 + .../commands/poll_transcription_results.py | 63 + .../management/commands/test_tingwu_local.py | 102 + .../ai_services/migrations/0001_initial.py | 34 + ...task_evaluation_transcriptiontask_score.py | 23 + ...riptiontask_auto_chapters_data_and_more.py | 28 + ...e_transcriptiontask_evaluation_and_more.py | 44 + ...ate_alter_aievaluation_options_and_more.py | 55 + .../0006_transcriptiontask_project.py | 20 + ...07_aievaluationtemplate_score_dimension.py | 20 + .../0008_add_is_default_to_template.py | 18 + ...uation_id_alter_aievaluationtemplate_id.py | 23 + backend/ai_services/migrations/__init__.py | 0 backend/ai_services/models.py | 150 ++ backend/ai_services/serializers.py | 28 + backend/ai_services/services.py | 420 ++++ backend/ai_services/tests.py | 3 + backend/ai_services/urls.py | 11 + backend/ai_services/views.py | 364 ++++ backend/check_urls.py | 30 + backend/community/__init__.py | 0 backend/community/admin.py | 404 ++++ backend/community/admin_actions.py | 149 ++ backend/community/apps.py | 5 + backend/community/migrations/0001_initial.py | 144 ++ .../migrations/0002_activity_author.py | 20 + .../migrations/0003_alter_activity_author.py | 20 + ...ity_id_alter_activitysignup_id_and_more.py | 43 + backend/community/migrations/__init__.py | 0 backend/community/models.py | 285 +++ backend/community/permissions.py | 23 + backend/community/serializers.py | 190 ++ backend/community/tests.py | 3 + backend/community/urls.py | 15 + backend/community/utils.py | 55 + backend/community/views.py | 516 +++++ backend/competition/__init__.py | 0 backend/competition/admin.py | 198 ++ backend/competition/apps.py | 6 + backend/competition/judge_urls.py | 21 + backend/competition/judge_views.py | 659 +++++++ .../competition/migrations/0001_initial.py | 141 ++ ...cover_image_url_project_cover_image_url.py | 23 + .../0003_competition_project_visibility.py | 18 + ...04_competition_allow_contestant_grading.py | 18 + .../0005_scoredimension_is_public.py | 18 + .../migrations/0006_add_peer_review_field.py | 18 + ...omepageconfig_alter_comment_id_and_more.py | 97 + .../0008_alter_carouselitem_image_url.py | 18 + backend/competition/migrations/__init__.py | 0 backend/competition/models.py | 337 ++++ backend/competition/serializers.py | 155 ++ .../templates/judge/ai_manage.html | 179 ++ backend/competition/templates/judge/base.html | 235 +++ .../templates/judge/dashboard.html | 823 ++++++++ .../competition/templates/judge/login.html | 129 ++ backend/competition/tests.py | 3 + backend/competition/urls.py | 27 + backend/competition/views.py | 319 ++++ backend/config/__init__.py | 0 backend/config/asgi.py | 16 + backend/config/settings.py | 404 ++++ backend/config/urls.py | 29 + backend/config/wsgi.py | 16 + backend/manage.py | 22 + backend/populate_db.py | 57 + backend/requirements.txt | 31 + backend/shop/__init__.py | 0 backend/shop/admin.py | 548 ++++++ backend/shop/admin_actions.py | 110 ++ backend/shop/apps.py | 9 + backend/shop/migrations/0001_initial.py | 50 + ...stomer_name_order_phone_number_and_more.py | 28 + ...rson_alter_esp32config_options_and_more.py | 36 + ..._esp32config_id_alter_order_id_and_more.py | 44 + ..._esp32config_id_alter_order_id_and_more.py | 50 + ...rvice_esp32config_detail_image_and_more.py | 58 + .../shop/migrations/0007_productfeature.py | 32 + ..._content_service_delivery_time_and_more.py | 55 + .../0009_esp32config_model_3d_url_and_more.py | 23 + .../0010_alter_esp32config_model_3d_url.py | 18 + .../0011_alter_esp32config_model_3d_url.py | 18 + ...0012_wechatpayconfig_apiv3_key_and_more.py | 33 + .../migrations/0013_order_out_trade_no.py | 18 + ...onfig_stock_order_courier_name_and_more.py | 28 + ...15_esp32config_commission_rate_and_more.py | 50 + ...echatuser_distributor_order_wechat_user.py | 64 + backend/shop/migrations/0017_withdrawal.py | 30 + .../0018_vbcourse_delete_arservice.py | 35 + ...mage_vbcourse_detail_image_url_and_more.py | 28 + .../0020_alter_vbcourse_course_type.py | 18 + ..._distributor_order_distributor_and_more.py | 29 + ...content_vbcourse_price_courseenrollment.py | 45 + .../0023_order_course_alter_order_config.py | 24 + ...024_vbcourse_instructor_avatar_and_more.py | 33 + ..._alter_courseenrollment_course_and_more.py | 55 + .../0026_wechatuser_phone_number.py | 18 + ...027_wechatuser_is_star_wechatuser_title.py | 23 + .../migrations/0028_fix_goodsid_schema.py | 14 + .../shop/migrations/0029_fix_legacy_fields.py | 14 + ..._options_alter_service_options_and_more.py | 46 + .../shop/migrations/0031_adminphonenumber.py | 27 + .../shop/migrations/0032_order_activity.py | 20 + ...s_fixed_schedule_vccourse_schedule_time.py | 23 + ...chedule_time_vccourse_end_time_and_more.py | 27 + .../shop/migrations/0035_wechatuser_skills.py | 18 + ...ter_wechatuser_options_wechatuser_order.py | 22 + .../0037_wechatuser_has_web_badge.py | 18 + ...urse_is_video_course_vccourse_video_url.py | 23 + .../0039_vccourse_video_embed_code.py | 18 + ...alter_courseenrollment_options_and_more.py | 128 ++ backend/shop/migrations/__init__.py | 0 backend/shop/models.py | 495 +++++ backend/shop/serializers.py | 368 ++++ backend/shop/services.py | 177 ++ backend/shop/signals.py | 65 + backend/shop/sms_utils.py | 144 ++ backend/shop/templates/shop/order_check.html | 151 ++ backend/shop/tests.py | 3 + backend/shop/urls.py | 31 + backend/shop/utils.py | 88 + backend/shop/views.py | 1693 +++++++++++++++++ backend/start_judge_system.sh | 18 + .../474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG | Bin 0 -> 36020 bytes deploy_market_page 2.sh | 39 + deploy_market_page.sh | 39 + docker-compose.yml | 31 + frontend/.gitignore | 24 + frontend/Dockerfile | 29 + frontend/README.md | 16 + frontend/eslint.config.js | 29 + frontend/index.html | 13 + frontend/package.json | 57 + frontend/public/logo.png | Bin 0 -> 49604 bytes frontend/public/shouye.png | Bin 0 -> 213769 bytes frontend/public/vite.svg | 1 + frontend/src/App.css | 41 + frontend/src/App.jsx | 69 + frontend/src/animation.js | 53 + frontend/src/api.js | 102 + frontend/src/assets/react.svg | 1 + frontend/src/components/CodeBlock.jsx | 48 + frontend/src/components/CreateTopicModal.jsx | 286 +++ frontend/src/components/Layout.jsx | 296 +++ frontend/src/components/LoginModal.jsx | 123 ++ frontend/src/components/ModelViewer.jsx | 218 +++ .../src/components/ParticleBackground.jsx | 174 ++ frontend/src/components/ProfileModal.jsx | 124 ++ .../src/components/activity/ActivityCard.jsx | 101 + .../activity/ActivityCard.stories.jsx | 67 + .../src/components/activity/ActivityList.jsx | 110 ++ .../components/activity/activity.module.less | 411 ++++ .../competition/CompetitionCard.jsx | 96 + .../competition/CompetitionDetail.jsx | 445 +++++ .../competition/CompetitionList.jsx | 91 + .../components/competition/ProjectDetail.jsx | 197 ++ .../competition/ProjectSubmission.jsx | 191 ++ frontend/src/context/AuthContext.jsx | 85 + frontend/src/index.css | 59 + frontend/src/main.jsx | 10 + frontend/src/pages/AIServices.jsx | 235 +++ frontend/src/pages/Activities.jsx | 34 + frontend/src/pages/ForumDetail.jsx | 538 ++++++ frontend/src/pages/ForumDetail.module.less | 109 ++ frontend/src/pages/ForumList.jsx | 429 +++++ frontend/src/pages/Home.css | 78 + frontend/src/pages/Home.jsx | 592 ++++++ frontend/src/pages/MyOrders.jsx | 388 ++++ frontend/src/pages/Payment.css | 52 + frontend/src/pages/Payment.jsx | 206 ++ frontend/src/pages/ProductDetail.css | 33 + frontend/src/pages/ProductDetail.jsx | 327 ++++ frontend/src/pages/ServiceDetail.jsx | 283 +++ frontend/src/pages/VCCourseDetail.jsx | 553 ++++++ frontend/src/pages/VCCourseDetail.module.less | 109 ++ frontend/src/pages/VCCourses.jsx | 121 ++ frontend/src/pages/activity/Detail.jsx | 631 ++++++ frontend/src/theme.module.less | 69 + frontend/vite.config.js | 61 + miniprogram/API.md | 37 + miniprogram/DEPLOY.md | 16 + miniprogram/README.md | 91 + miniprogram/babel.config.js | 9 + miniprogram/config/dev.js | 9 + miniprogram/config/index.js | 87 + miniprogram/config/prod.js | 18 + miniprogram/e2e/home.spec.js | 22 + miniprogram/package.json | 68 + miniprogram/project.config.json | 63 + miniprogram/project.private.config.json | 22 + miniprogram/src/api/index.ts | 126 ++ miniprogram/src/app.config.ts | 92 + miniprogram/src/app.scss | 22 + miniprogram/src/app.ts | 51 + miniprogram/src/assets/AI_service.png | Bin 0 -> 3489 bytes miniprogram/src/assets/AI_service_active.png | Bin 0 -> 4470 bytes miniprogram/src/assets/VR.png | Bin 0 -> 4202 bytes miniprogram/src/assets/VR_active.png | Bin 0 -> 3744 bytes miniprogram/src/assets/cart.png | Bin 0 -> 3745 bytes miniprogram/src/assets/cart_active.png | Bin 0 -> 3881 bytes miniprogram/src/assets/home.png | Bin 0 -> 2582 bytes miniprogram/src/assets/home_active.png | Bin 0 -> 10496 bytes miniprogram/src/assets/logo.svg | 104 + miniprogram/src/assets/user.png | Bin 0 -> 4204 bytes miniprogram/src/assets/user_active.png | Bin 0 -> 3533 bytes .../components/MarkdownReader/CodeBlock.tsx | 42 + .../src/components/MarkdownReader/index.scss | 101 + .../src/components/MarkdownReader/index.tsx | 146 ++ .../components/ParticleBackground/index.scss | 10 + .../components/ParticleBackground/index.tsx | 175 ++ miniprogram/src/pages/cart/cart.config.ts | 3 + miniprogram/src/pages/cart/cart.scss | 214 +++ miniprogram/src/pages/cart/cart.tsx | 147 ++ miniprogram/src/pages/competition/detail.scss | 378 ++++ miniprogram/src/pages/competition/detail.tsx | 318 ++++ miniprogram/src/pages/competition/index.scss | 85 + miniprogram/src/pages/competition/index.tsx | 110 ++ .../competition/project-detail.config.ts | 3 + .../src/pages/competition/project-detail.scss | 288 +++ .../src/pages/competition/project-detail.tsx | 191 ++ .../src/pages/competition/project.config.ts | 3 + .../src/pages/competition/project.scss | 93 + miniprogram/src/pages/competition/project.tsx | 279 +++ .../src/pages/courses/detail.config.ts | 3 + miniprogram/src/pages/courses/detail.scss | 365 ++++ miniprogram/src/pages/courses/detail.tsx | 258 +++ miniprogram/src/pages/courses/index.config.ts | 3 + miniprogram/src/pages/courses/index.scss | 148 ++ miniprogram/src/pages/courses/index.tsx | 81 + miniprogram/src/pages/forum/index.config.ts | 7 + miniprogram/src/pages/forum/index.scss | 683 +++++++ miniprogram/src/pages/forum/index.tsx | 374 ++++ miniprogram/src/pages/goods/detail.config.ts | 3 + miniprogram/src/pages/goods/detail.scss | 365 ++++ miniprogram/src/pages/goods/detail.tsx | 192 ++ miniprogram/src/pages/index/index.config.ts | 3 + miniprogram/src/pages/index/index.scss | 570 ++++++ miniprogram/src/pages/index/index.tsx | 258 +++ .../src/pages/order/checkout.config.ts | 3 + miniprogram/src/pages/order/checkout.scss | 191 ++ miniprogram/src/pages/order/checkout.tsx | 265 +++ miniprogram/src/pages/order/detail.config.ts | 3 + miniprogram/src/pages/order/detail.scss | 86 + miniprogram/src/pages/order/detail.tsx | 143 ++ miniprogram/src/pages/order/list.config.ts | 3 + miniprogram/src/pages/order/list.scss | 98 + miniprogram/src/pages/order/list.tsx | 63 + miniprogram/src/pages/order/payment.config.ts | 3 + miniprogram/src/pages/order/payment.scss | 70 + miniprogram/src/pages/order/payment.tsx | 102 + .../src/pages/services/detail.config.ts | 3 + miniprogram/src/pages/services/detail.scss | 245 +++ miniprogram/src/pages/services/detail.tsx | 176 ++ .../src/pages/services/index.config.ts | 3 + miniprogram/src/pages/services/index.scss | 414 ++++ miniprogram/src/pages/services/index.tsx | 143 ++ miniprogram/src/pages/user/index.config.ts | 5 + miniprogram/src/pages/user/index.scss | 443 +++++ miniprogram/src/pages/user/index.tsx | 517 +++++ miniprogram/src/pages/webview/index.config.ts | 3 + miniprogram/src/pages/webview/index.tsx | 14 + .../src/subpackages/distributor/_shared.scss | 72 + .../distributor/earnings.config.ts | 3 + .../src/subpackages/distributor/earnings.scss | 56 + .../src/subpackages/distributor/earnings.tsx | 55 + .../subpackages/distributor/index.config.ts | 3 + .../src/subpackages/distributor/index.scss | 112 ++ .../src/subpackages/distributor/index.tsx | 83 + .../subpackages/distributor/invite.config.ts | 3 + .../src/subpackages/distributor/invite.scss | 49 + .../src/subpackages/distributor/invite.tsx | 57 + .../subpackages/distributor/orders.config.ts | 3 + .../src/subpackages/distributor/orders.scss | 72 + .../src/subpackages/distributor/orders.tsx | 58 + .../distributor/register.config.ts | 3 + .../src/subpackages/distributor/register.scss | 46 + .../src/subpackages/distributor/register.tsx | 32 + .../subpackages/distributor/team.config.ts | 3 + .../src/subpackages/distributor/team.scss | 85 + .../src/subpackages/distributor/team.tsx | 62 + .../distributor/withdraw.config.ts | 3 + .../src/subpackages/distributor/withdraw.scss | 60 + .../src/subpackages/distributor/withdraw.tsx | 73 + .../forum/activity/detail.config.ts | 3 + .../subpackages/forum/activity/detail.scss | 421 ++++ .../src/subpackages/forum/activity/detail.tsx | 387 ++++ .../forum/activity/index.config.ts | 4 + .../src/subpackages/forum/activity/index.scss | 164 ++ .../src/subpackages/forum/activity/index.tsx | 141 ++ .../subpackages/forum/create/create.config.ts | 6 + .../src/subpackages/forum/create/create.scss | 210 ++ .../src/subpackages/forum/create/index.tsx | 208 ++ .../subpackages/forum/detail/detail.config.ts | 6 + .../src/subpackages/forum/detail/detail.scss | 629 ++++++ .../src/subpackages/forum/detail/index.tsx | 380 ++++ miniprogram/src/utils/auth.ts | 28 + miniprogram/src/utils/cart.ts | 90 + miniprogram/src/utils/format.test.ts | 9 + miniprogram/src/utils/format.ts | 3 + miniprogram/src/utils/request.ts | 76 + miniprogram/tsconfig.json | 37 + 315 files changed, 36043 insertions(+) create mode 100644 .gitea/workflows/deploy.yaml create mode 100644 .gitignore create mode 100644 README 2.md create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/DEPLOY.md create mode 100644 backend/Dockerfile create mode 100644 backend/TEST_REPORT.md create mode 100644 backend/ai_services/__init__.py create mode 100644 backend/ai_services/admin.py create mode 100644 backend/ai_services/apps.py create mode 100644 backend/ai_services/bailian_service.py create mode 100644 backend/ai_services/management/__init__.py create mode 100644 backend/ai_services/management/commands/__init__.py create mode 100644 backend/ai_services/management/commands/check_aliyun_config.py create mode 100644 backend/ai_services/management/commands/poll_transcription_results.py create mode 100644 backend/ai_services/management/commands/test_tingwu_local.py create mode 100644 backend/ai_services/migrations/0001_initial.py create mode 100644 backend/ai_services/migrations/0002_transcriptiontask_evaluation_transcriptiontask_score.py create mode 100644 backend/ai_services/migrations/0003_transcriptiontask_auto_chapters_data_and_more.py create mode 100644 backend/ai_services/migrations/0004_remove_transcriptiontask_evaluation_and_more.py create mode 100644 backend/ai_services/migrations/0005_aievaluationtemplate_alter_aievaluation_options_and_more.py create mode 100644 backend/ai_services/migrations/0006_transcriptiontask_project.py create mode 100644 backend/ai_services/migrations/0007_aievaluationtemplate_score_dimension.py create mode 100644 backend/ai_services/migrations/0008_add_is_default_to_template.py create mode 100644 backend/ai_services/migrations/0009_alter_aievaluation_id_alter_aievaluationtemplate_id.py create mode 100644 backend/ai_services/migrations/__init__.py create mode 100644 backend/ai_services/models.py create mode 100644 backend/ai_services/serializers.py create mode 100644 backend/ai_services/services.py create mode 100644 backend/ai_services/tests.py create mode 100644 backend/ai_services/urls.py create mode 100644 backend/ai_services/views.py create mode 100644 backend/check_urls.py create mode 100644 backend/community/__init__.py create mode 100644 backend/community/admin.py create mode 100644 backend/community/admin_actions.py create mode 100644 backend/community/apps.py create mode 100644 backend/community/migrations/0001_initial.py create mode 100644 backend/community/migrations/0002_activity_author.py create mode 100644 backend/community/migrations/0003_alter_activity_author.py create mode 100644 backend/community/migrations/0004_alter_activity_id_alter_activitysignup_id_and_more.py create mode 100644 backend/community/migrations/__init__.py create mode 100644 backend/community/models.py create mode 100644 backend/community/permissions.py create mode 100644 backend/community/serializers.py create mode 100644 backend/community/tests.py create mode 100644 backend/community/urls.py create mode 100644 backend/community/utils.py create mode 100644 backend/community/views.py create mode 100644 backend/competition/__init__.py create mode 100644 backend/competition/admin.py create mode 100644 backend/competition/apps.py create mode 100644 backend/competition/judge_urls.py create mode 100644 backend/competition/judge_views.py create mode 100644 backend/competition/migrations/0001_initial.py create mode 100644 backend/competition/migrations/0002_competition_cover_image_url_project_cover_image_url.py create mode 100644 backend/competition/migrations/0003_competition_project_visibility.py create mode 100644 backend/competition/migrations/0004_competition_allow_contestant_grading.py create mode 100644 backend/competition/migrations/0005_scoredimension_is_public.py create mode 100644 backend/competition/migrations/0006_add_peer_review_field.py create mode 100644 backend/competition/migrations/0007_carouselitem_homepageconfig_alter_comment_id_and_more.py create mode 100644 backend/competition/migrations/0008_alter_carouselitem_image_url.py create mode 100644 backend/competition/migrations/__init__.py create mode 100644 backend/competition/models.py create mode 100644 backend/competition/serializers.py create mode 100644 backend/competition/templates/judge/ai_manage.html create mode 100644 backend/competition/templates/judge/base.html create mode 100644 backend/competition/templates/judge/dashboard.html create mode 100644 backend/competition/templates/judge/login.html create mode 100644 backend/competition/tests.py create mode 100644 backend/competition/urls.py create mode 100644 backend/competition/views.py create mode 100644 backend/config/__init__.py create mode 100644 backend/config/asgi.py create mode 100644 backend/config/settings.py create mode 100644 backend/config/urls.py create mode 100644 backend/config/wsgi.py create mode 100644 backend/manage.py create mode 100644 backend/populate_db.py create mode 100644 backend/requirements.txt create mode 100644 backend/shop/__init__.py create mode 100644 backend/shop/admin.py create mode 100644 backend/shop/admin_actions.py create mode 100644 backend/shop/apps.py create mode 100644 backend/shop/migrations/0001_initial.py create mode 100644 backend/shop/migrations/0002_order_customer_name_order_phone_number_and_more.py create mode 100644 backend/shop/migrations/0003_salesperson_alter_esp32config_options_and_more.py create mode 100644 backend/shop/migrations/0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more.py create mode 100644 backend/shop/migrations/0005_service_alter_esp32config_id_alter_order_id_and_more.py create mode 100644 backend/shop/migrations/0006_arservice_esp32config_detail_image_and_more.py create mode 100644 backend/shop/migrations/0007_productfeature.py create mode 100644 backend/shop/migrations/0008_service_delivery_content_service_delivery_time_and_more.py create mode 100644 backend/shop/migrations/0009_esp32config_model_3d_url_and_more.py create mode 100644 backend/shop/migrations/0010_alter_esp32config_model_3d_url.py create mode 100644 backend/shop/migrations/0011_alter_esp32config_model_3d_url.py create mode 100644 backend/shop/migrations/0012_wechatpayconfig_apiv3_key_and_more.py create mode 100644 backend/shop/migrations/0013_order_out_trade_no.py create mode 100644 backend/shop/migrations/0014_esp32config_stock_order_courier_name_and_more.py create mode 100644 backend/shop/migrations/0015_esp32config_commission_rate_and_more.py create mode 100644 backend/shop/migrations/0016_wechatuser_distributor_order_wechat_user.py create mode 100644 backend/shop/migrations/0017_withdrawal.py create mode 100644 backend/shop/migrations/0018_vbcourse_delete_arservice.py create mode 100644 backend/shop/migrations/0019_vbcourse_detail_image_vbcourse_detail_image_url_and_more.py create mode 100644 backend/shop/migrations/0020_alter_vbcourse_course_type.py create mode 100644 backend/shop/migrations/0021_commissionlog_distributor_order_distributor_and_more.py create mode 100644 backend/shop/migrations/0022_vbcourse_content_vbcourse_price_courseenrollment.py create mode 100644 backend/shop/migrations/0023_order_course_alter_order_config.py create mode 100644 backend/shop/migrations/0024_vbcourse_instructor_avatar_and_more.py create mode 100644 backend/shop/migrations/0025_vccourse_alter_courseenrollment_course_and_more.py create mode 100644 backend/shop/migrations/0026_wechatuser_phone_number.py create mode 100644 backend/shop/migrations/0027_wechatuser_is_star_wechatuser_title.py create mode 100644 backend/shop/migrations/0028_fix_goodsid_schema.py create mode 100644 backend/shop/migrations/0029_fix_legacy_fields.py create mode 100644 backend/shop/migrations/0030_alter_esp32config_options_alter_service_options_and_more.py create mode 100644 backend/shop/migrations/0031_adminphonenumber.py create mode 100644 backend/shop/migrations/0032_order_activity.py create mode 100644 backend/shop/migrations/0033_vccourse_is_fixed_schedule_vccourse_schedule_time.py create mode 100644 backend/shop/migrations/0034_remove_vccourse_schedule_time_vccourse_end_time_and_more.py create mode 100644 backend/shop/migrations/0035_wechatuser_skills.py create mode 100644 backend/shop/migrations/0036_alter_wechatuser_options_wechatuser_order.py create mode 100644 backend/shop/migrations/0037_wechatuser_has_web_badge.py create mode 100644 backend/shop/migrations/0038_vccourse_is_video_course_vccourse_video_url.py create mode 100644 backend/shop/migrations/0039_vccourse_video_embed_code.py create mode 100644 backend/shop/migrations/0040_identitytag_alter_courseenrollment_options_and_more.py create mode 100644 backend/shop/migrations/__init__.py create mode 100644 backend/shop/models.py create mode 100644 backend/shop/serializers.py create mode 100644 backend/shop/services.py create mode 100644 backend/shop/signals.py create mode 100644 backend/shop/sms_utils.py create mode 100644 backend/shop/templates/shop/order_check.html create mode 100644 backend/shop/tests.py create mode 100644 backend/shop/urls.py create mode 100644 backend/shop/utils.py create mode 100644 backend/shop/views.py create mode 100755 backend/start_judge_system.sh create mode 100644 backend/uploads/avatars/474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG create mode 100644 deploy_market_page 2.sh create mode 100644 deploy_market_page.sh create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/public/logo.png create mode 100644 frontend/public/shouye.png create mode 100644 frontend/public/vite.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/animation.js create mode 100644 frontend/src/api.js create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/components/CodeBlock.jsx create mode 100644 frontend/src/components/CreateTopicModal.jsx create mode 100644 frontend/src/components/Layout.jsx create mode 100644 frontend/src/components/LoginModal.jsx create mode 100644 frontend/src/components/ModelViewer.jsx create mode 100644 frontend/src/components/ParticleBackground.jsx create mode 100644 frontend/src/components/ProfileModal.jsx create mode 100644 frontend/src/components/activity/ActivityCard.jsx create mode 100644 frontend/src/components/activity/ActivityCard.stories.jsx create mode 100644 frontend/src/components/activity/ActivityList.jsx create mode 100644 frontend/src/components/activity/activity.module.less create mode 100644 frontend/src/components/competition/CompetitionCard.jsx create mode 100644 frontend/src/components/competition/CompetitionDetail.jsx create mode 100644 frontend/src/components/competition/CompetitionList.jsx create mode 100644 frontend/src/components/competition/ProjectDetail.jsx create mode 100644 frontend/src/components/competition/ProjectSubmission.jsx create mode 100644 frontend/src/context/AuthContext.jsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/AIServices.jsx create mode 100644 frontend/src/pages/Activities.jsx create mode 100644 frontend/src/pages/ForumDetail.jsx create mode 100644 frontend/src/pages/ForumDetail.module.less create mode 100644 frontend/src/pages/ForumList.jsx create mode 100644 frontend/src/pages/Home.css create mode 100644 frontend/src/pages/Home.jsx create mode 100644 frontend/src/pages/MyOrders.jsx create mode 100644 frontend/src/pages/Payment.css create mode 100644 frontend/src/pages/Payment.jsx create mode 100644 frontend/src/pages/ProductDetail.css create mode 100644 frontend/src/pages/ProductDetail.jsx create mode 100644 frontend/src/pages/ServiceDetail.jsx create mode 100644 frontend/src/pages/VCCourseDetail.jsx create mode 100644 frontend/src/pages/VCCourseDetail.module.less create mode 100644 frontend/src/pages/VCCourses.jsx create mode 100644 frontend/src/pages/activity/Detail.jsx create mode 100644 frontend/src/theme.module.less create mode 100644 frontend/vite.config.js create mode 100644 miniprogram/API.md create mode 100644 miniprogram/DEPLOY.md create mode 100644 miniprogram/README.md create mode 100644 miniprogram/babel.config.js create mode 100644 miniprogram/config/dev.js create mode 100644 miniprogram/config/index.js create mode 100644 miniprogram/config/prod.js create mode 100644 miniprogram/e2e/home.spec.js create mode 100644 miniprogram/package.json create mode 100644 miniprogram/project.config.json create mode 100644 miniprogram/project.private.config.json create mode 100644 miniprogram/src/api/index.ts create mode 100644 miniprogram/src/app.config.ts create mode 100644 miniprogram/src/app.scss create mode 100644 miniprogram/src/app.ts create mode 100644 miniprogram/src/assets/AI_service.png create mode 100644 miniprogram/src/assets/AI_service_active.png create mode 100644 miniprogram/src/assets/VR.png create mode 100644 miniprogram/src/assets/VR_active.png create mode 100644 miniprogram/src/assets/cart.png create mode 100644 miniprogram/src/assets/cart_active.png create mode 100644 miniprogram/src/assets/home.png create mode 100644 miniprogram/src/assets/home_active.png create mode 100644 miniprogram/src/assets/logo.svg create mode 100644 miniprogram/src/assets/user.png create mode 100644 miniprogram/src/assets/user_active.png create mode 100644 miniprogram/src/components/MarkdownReader/CodeBlock.tsx create mode 100644 miniprogram/src/components/MarkdownReader/index.scss create mode 100644 miniprogram/src/components/MarkdownReader/index.tsx create mode 100644 miniprogram/src/components/ParticleBackground/index.scss create mode 100644 miniprogram/src/components/ParticleBackground/index.tsx create mode 100644 miniprogram/src/pages/cart/cart.config.ts create mode 100644 miniprogram/src/pages/cart/cart.scss create mode 100644 miniprogram/src/pages/cart/cart.tsx create mode 100644 miniprogram/src/pages/competition/detail.scss create mode 100644 miniprogram/src/pages/competition/detail.tsx create mode 100644 miniprogram/src/pages/competition/index.scss create mode 100644 miniprogram/src/pages/competition/index.tsx create mode 100644 miniprogram/src/pages/competition/project-detail.config.ts create mode 100644 miniprogram/src/pages/competition/project-detail.scss create mode 100644 miniprogram/src/pages/competition/project-detail.tsx create mode 100644 miniprogram/src/pages/competition/project.config.ts create mode 100644 miniprogram/src/pages/competition/project.scss create mode 100644 miniprogram/src/pages/competition/project.tsx create mode 100644 miniprogram/src/pages/courses/detail.config.ts create mode 100644 miniprogram/src/pages/courses/detail.scss create mode 100644 miniprogram/src/pages/courses/detail.tsx create mode 100644 miniprogram/src/pages/courses/index.config.ts create mode 100644 miniprogram/src/pages/courses/index.scss create mode 100644 miniprogram/src/pages/courses/index.tsx create mode 100644 miniprogram/src/pages/forum/index.config.ts create mode 100644 miniprogram/src/pages/forum/index.scss create mode 100644 miniprogram/src/pages/forum/index.tsx create mode 100644 miniprogram/src/pages/goods/detail.config.ts create mode 100644 miniprogram/src/pages/goods/detail.scss create mode 100644 miniprogram/src/pages/goods/detail.tsx create mode 100644 miniprogram/src/pages/index/index.config.ts create mode 100644 miniprogram/src/pages/index/index.scss create mode 100644 miniprogram/src/pages/index/index.tsx create mode 100644 miniprogram/src/pages/order/checkout.config.ts create mode 100644 miniprogram/src/pages/order/checkout.scss create mode 100644 miniprogram/src/pages/order/checkout.tsx create mode 100644 miniprogram/src/pages/order/detail.config.ts create mode 100644 miniprogram/src/pages/order/detail.scss create mode 100644 miniprogram/src/pages/order/detail.tsx create mode 100644 miniprogram/src/pages/order/list.config.ts create mode 100644 miniprogram/src/pages/order/list.scss create mode 100644 miniprogram/src/pages/order/list.tsx create mode 100644 miniprogram/src/pages/order/payment.config.ts create mode 100644 miniprogram/src/pages/order/payment.scss create mode 100644 miniprogram/src/pages/order/payment.tsx create mode 100644 miniprogram/src/pages/services/detail.config.ts create mode 100644 miniprogram/src/pages/services/detail.scss create mode 100644 miniprogram/src/pages/services/detail.tsx create mode 100644 miniprogram/src/pages/services/index.config.ts create mode 100644 miniprogram/src/pages/services/index.scss create mode 100644 miniprogram/src/pages/services/index.tsx create mode 100644 miniprogram/src/pages/user/index.config.ts create mode 100644 miniprogram/src/pages/user/index.scss create mode 100644 miniprogram/src/pages/user/index.tsx create mode 100644 miniprogram/src/pages/webview/index.config.ts create mode 100644 miniprogram/src/pages/webview/index.tsx create mode 100644 miniprogram/src/subpackages/distributor/_shared.scss create mode 100644 miniprogram/src/subpackages/distributor/earnings.config.ts create mode 100644 miniprogram/src/subpackages/distributor/earnings.scss create mode 100644 miniprogram/src/subpackages/distributor/earnings.tsx create mode 100644 miniprogram/src/subpackages/distributor/index.config.ts create mode 100644 miniprogram/src/subpackages/distributor/index.scss create mode 100644 miniprogram/src/subpackages/distributor/index.tsx create mode 100644 miniprogram/src/subpackages/distributor/invite.config.ts create mode 100644 miniprogram/src/subpackages/distributor/invite.scss create mode 100644 miniprogram/src/subpackages/distributor/invite.tsx create mode 100644 miniprogram/src/subpackages/distributor/orders.config.ts create mode 100644 miniprogram/src/subpackages/distributor/orders.scss create mode 100644 miniprogram/src/subpackages/distributor/orders.tsx create mode 100644 miniprogram/src/subpackages/distributor/register.config.ts create mode 100644 miniprogram/src/subpackages/distributor/register.scss create mode 100644 miniprogram/src/subpackages/distributor/register.tsx create mode 100644 miniprogram/src/subpackages/distributor/team.config.ts create mode 100644 miniprogram/src/subpackages/distributor/team.scss create mode 100644 miniprogram/src/subpackages/distributor/team.tsx create mode 100644 miniprogram/src/subpackages/distributor/withdraw.config.ts create mode 100644 miniprogram/src/subpackages/distributor/withdraw.scss create mode 100644 miniprogram/src/subpackages/distributor/withdraw.tsx create mode 100644 miniprogram/src/subpackages/forum/activity/detail.config.ts create mode 100644 miniprogram/src/subpackages/forum/activity/detail.scss create mode 100644 miniprogram/src/subpackages/forum/activity/detail.tsx create mode 100644 miniprogram/src/subpackages/forum/activity/index.config.ts create mode 100644 miniprogram/src/subpackages/forum/activity/index.scss create mode 100644 miniprogram/src/subpackages/forum/activity/index.tsx create mode 100644 miniprogram/src/subpackages/forum/create/create.config.ts create mode 100644 miniprogram/src/subpackages/forum/create/create.scss create mode 100644 miniprogram/src/subpackages/forum/create/index.tsx create mode 100644 miniprogram/src/subpackages/forum/detail/detail.config.ts create mode 100644 miniprogram/src/subpackages/forum/detail/detail.scss create mode 100644 miniprogram/src/subpackages/forum/detail/index.tsx create mode 100644 miniprogram/src/utils/auth.ts create mode 100644 miniprogram/src/utils/cart.ts create mode 100644 miniprogram/src/utils/format.test.ts create mode 100644 miniprogram/src/utils/format.ts create mode 100644 miniprogram/src/utils/request.ts create mode 100644 miniprogram/tsconfig.json diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml new file mode 100644 index 0000000..0f3f595 --- /dev/null +++ b/.gitea/workflows/deploy.yaml @@ -0,0 +1,61 @@ +name: Deploy to Server +on: [push] + +jobs: + deploy: + runs-on: ubuntu + steps: + - name: Deploy using SSH + # 使用 Gitea 官方镜像源,加速国内访问 + uses: https://gitea.com/actions/appleboy-ssh-action@v1.0.3 + with: + host: 6.6.6.66 + username: quant + password: 123quant-speed + script: | + TARGET_DIR="/home/quant/data/dev/market_page" + SUDO_PASSWORD="123quant-speed" + + # 1. 切换到目标目录 + echo "===== 切换到目标目录: $TARGET_DIR =====" + cd $TARGET_DIR || { + echo "错误:目录 $TARGET_DIR 不存在!" + exit 1 + } + + # 2. 停止并移除 Docker 容器及镜像 + echo -e "\n===== 停止并清理 Docker =====" + # 移除 --rmi all,保留镜像缓存,加快构建速度,同时避免误删基础镜像 + echo $SUDO_PASSWORD | sudo -S docker compose down + + # 3. 拉取 Git 最新代码 + echo -e "\n===== 拉取 Git 代码 =====" + # 尝试拉取,如果失败则强制重置,增强鲁棒性 + if ! git pull; then + echo "警告:Git pull 失败,尝试强制同步远程代码..." + git fetch --all + # 获取当前分支名并重置 + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + git reset --hard origin/$CURRENT_BRANCH + git pull + fi + + # 3.1 创建/更新 .env 文件 (从本地环境变量注入) + echo -e "\n===== 配置环境变量 =====" + cat > backend/.env < 一个集成了电商、社区论坛、AI 服务与 AR/3D 模型展示的全栈应用平台。 + +![Project Logo](frontend/public/liangji_logo.svg) + +## 📖 项目简介 +npm run dev:weapp +Quant Speed Market 是一个基于现代技术栈构建的综合性平台,旨在为用户提供从商品购买、技术交流到 AI 工具使用的全方位体验。项目采用前后端分离架构,包含 Django 后端 API、React Web 管理端以及 Taro 微信小程序客户端。 + +## ✨ 功能特性 + +- **🛍️ 电商商城**:支持商品浏览、购物车、微信支付 (WeChat Pay V3)、订单管理。 +- **💬 社区论坛**:支持发帖、回帖、话题分类、富文本编辑。 +- **🤖 AI 服务**:集成 AI 工具,提供智能辅助服务。 +- **🕶️ AR/3D 展示**:基于 Three.js 的 3D 模型预览与 AR 交互体验。 +- **📱 多端适配**:微信小程序原生体验,Web 端响应式管理后台。 +- **🔒 安全认证**:微信一键登录、手机号绑定、JWT 认证。 + +## 🛠️ 技术栈与依赖 + +### Backend (后端) +- **Framework**: Django 6.0 + Django REST Framework 3.16 +- **Database**: PostgreSQL (psycopg2) +- **Payment**: WeChat Pay V3 (wechatpayv3) +- **Documentation**: drf-spectacular (OpenAPI 3.0) +- **Deployment**: Docker, Gunicorn + +### Frontend (Web 端) +- **Core**: React 19 + Vite 7 +- **UI Library**: Ant Design 6 +- **3D Engine**: Three.js + @react-three/fiber +- **Routing**: React Router v7 + +### Miniprogram (小程序) +- **Framework**: Taro 3.6 (React Flavor) +- **UI Library**: Taro UI +- **Styles**: SCSS +- **Platform**: WeChat Mini Program (可扩展至 H5/Alipay 等) + +## 🚀 本地开发环境搭建 + +### 1. 系统要求 +- **Node.js**: >= 18.0.0 +- **Python**: >= 3.10 +- **PostgreSQL**: >= 13 +- **WeChat DevTools**: 最新版 (用于小程序开发) + +### 2. 克隆仓库 +```bash +git clone +cd market_page +``` + +### 3. 后端环境配置 (Backend) +```bash +cd backend + +# 创建虚拟环境 (推荐) +python -m venv venv +# Windows 激活 +venv\Scripts\activate +# macOS/Linux 激活 +source venv/bin/activate + +# 安装依赖 +pip install -r requirements.txt + +# 数据库迁移 +python manage.py migrate + +# 创建超级用户 +python manage.py createsuperuser + +# 启动开发服务器 (默认端口 8000) +python manage.py runserver +``` + +### 4. Web 前端配置 (Frontend) +```bash +cd ../frontend + +# 安装依赖 +npm install + +# 启动开发服务器 (默认端口 5173) +npm run dev +``` + +### 5. 小程序配置 (Miniprogram) +```bash +cd ../miniprogram + +# 安装依赖 +npm install + +# 编译并监听 (微信小程序) +npm run dev:weapp +``` +*启动后,请打开微信开发者工具,导入 `miniprogram` 目录进行预览。* + +## 📦 构建与运行 + +### Backend +```bash +# 收集静态文件 +python manage.py collectstatic --noinput + +# 使用 Gunicorn 运行 (生产环境) +gunicorn config.wsgi:application --bind 0.0.0.0:8000 +``` + +### Frontend +```bash +# 构建生产版本 +npm run build + +# 预览构建产物 +npm run preview +``` + +### Miniprogram +```bash +# 构建生产版本 (微信小程序) +npm run build:weapp +``` + +## 🧪 测试与覆盖率 + +### Backend +```bash +# 运行所有测试 +python manage.py test + +# 运行特定模块测试 +python manage.py test shop.tests +``` + +### Frontend / Miniprogram +```bash +# 代码风格检查 +npm run lint +``` + +## 🚢 部署指南 + +### Docker 部署 (推荐) +项目包含 `Dockerfile` 和 `docker-compose.yml` (根目录下),可一键启动。 + +```bash +# 在项目根目录 +docker-compose up -d --build +``` +*注意:请确保已在 `backend/config/settings.py` 或环境变量中配置好生产环境的数据库连接和密钥。* + +## 🔌 API 接口示例 + +后端提供 RESTful API,以下为核心接口示例: + +| 方法 | 路径 | 描述 | +| --- | --- | --- | +| POST | `/api/shop/wechat/login/` | 微信用户登录 (换取 JWT) | +| GET | `/api/shop/configs/` | 获取 ESP32/商品配置列表 | +| POST | `/api/shop/orders/` | 创建新订单 | +| POST | `/api/shop/pay/` | 发起微信支付 | +| GET | `/api/community/topics/` | 获取论坛话题列表 | + +**API 文档**: 启动后端后访问 `http://localhost:8000/api/schema/swagger-ui/` 查看完整 Swagger 文档。 + +## 📂 目录结构说明 + +``` +market_page/ +├── backend/ # Django 后端源码 +│ ├── community/ # 论坛社区模块 +│ ├── shop/ # 电商与支付模块 +│ ├── config/ # 项目核心配置 +│ ├── uploads/ # 用户上传文件 (媒体资源) +│ ├── manage.py # Django 管理脚本 +│ └── requirements.txt # Python 依赖 +├── frontend/ # React Web 端源码 +│ ├── src/ +│ │ ├── components/ # 公共组件 (3D模型、弹窗等) +│ │ ├── pages/ # 页面路由 (Home, Forum, Payment) +│ │ └── assets/ # 静态资源 +│ └── vite.config.js # Vite 配置 +├── miniprogram/ # Taro 小程序源码 +│ ├── src/ +│ │ ├── pages/ # 小程序页面 +│ │ ├── subpackages/ # 分包页面 (分销、论坛详情等) +│ │ └── components/ # 小程序组件 +│ └── project.config.json # 微信小程序配置 +└── docker-compose.yml # Docker 编排文件 +``` + +## 🤝 贡献规范 + +欢迎提交 Pull Request!请遵循以下规范: + +1. **分支管理**: + - `main`: 主分支,保持稳定。 + - `dev`: 开发分支。 + - `feat/xxx`: 新功能分支。 + - `fix/xxx`: Bug 修复分支。 + +2. **Commit 格式**: + - `feat: 添加购物车功能` + - `fix: 修复支付回调失败问题` + - `docs: 更新 README` + - `style: 调整首页样式` + +3. **PR 流程**: + - Fork 本仓库。 + - 创建特性分支。 + - 提交代码并推送到您的 Fork。 + - 提交 PR 至 `dev` 分支。 + +## ❓ 常见问题排查 + +- **Q: 后端启动报错 `psycopg2` 相关错误?** + - A: 请确保本地已安装 PostgreSQL 并且开发库 (`libpq-dev` 或 equivalent) 已就绪。 + +- **Q: 小程序报错 "appID 不合法"?** + - A: 请在 `miniprogram/project.config.json` 中修改 `appid` 为您自己的测试 ID,或在开发者工具中开启 "不校验合法域名"。 + +- **Q: 微信支付接口调用失败?** + - A: 微信支付依赖真实商户号和证书,本地开发请使用模拟数据或沙箱环境。 + +## 📜 许可证 + +本项目采用 [MIT License](LICENSE) 许可证。 + +## 📧 联系方式 + +- **作者**: (Your Name/Organization) +- **邮箱**: contact@example.com +- **项目主页**: https://github.com/yourusername/market-page diff --git a/README.md b/README.md new file mode 100644 index 0000000..6a61c38 --- /dev/null +++ b/README.md @@ -0,0 +1,312 @@ +# Quant Speed Market (量迹市场) + +> 一个集成了电商、社区论坛、AI 服务与 AR/3D 模型展示的全栈应用平台。 + +![Project Logo](frontend/public/liangji_logo.svg) + +## 📖 项目简介 +npm run dev:weapp +Quant Speed Market 是一个基于现代技术栈构建的综合性平台,旨在为用户提供从商品购买、技术交流到 AI 工具使用的全方位体验。项目采用前后端分离架构,包含 Django 后端 API、React Web 管理端以及 Taro 微信小程序客户端。 + +## ✨ 功能特性 + +### 🛍️ 电商商城系统 +- **商品管理**:ESP32硬件配置、库存管理、3D模型展示、产品特性标签 +- **订单管理**:多类型订单(硬件/课程/活动)、完整状态流转、物流跟踪 +- **支付系统**:微信支付V3集成、多种支付方式、安全签名验证、支付回调处理 +- **分销系统**:二级分销体系、邀请机制、佣金计算(一级10%/二级2%)、提现管理 +- **课程系统**:视频课程、固定时间课程、讲师管理、课程报名与咨询 + +### 💬 社区论坛系统 +- **活动管理**:线上线下活动、报名表单自定义、支付状态同步、审核机制 +- **论坛帖子**:技术讨论、求助问答、经验分享、官方公告四大分类 +- **互动功能**:点赞、置顶、嵌套回复(楼中楼)、多媒体附件支持 +- **公告系统**:时效控制、跳转链接、优先级排序、置顶功能 + +### 🤖 AI 服务系统 +- **语音转写**:阿里云听悟集成、多格式音频支持、说话人分离、状态自动刷新 +- **AI智能评估**:多模型支持(通义千问系列)、模板化评估、0-100分制评分、详细评语生成 +- **智能总结**:多类型总结(段落/对话/问答/思维导图)、Markdown格式输出、异步生成机制 +- **比赛集成**:AI评委身份、评分维度映射、自动评分同步、人工干预支持 + +### 🏆 竞赛评审系统 +- **比赛管理**:多状态流程(草稿→发布→报名→提交→评审→结束)、时间管理、可见性控制 +- **项目管理**:文件附件支持(PPT/PDF/图片/视频)、封面展示、状态管理 +- **评分系统**:多维度评分、权重配置、评委评语、防重复评分机制 +- **权限控制**:选手/评委/嘉宾三角色体系、报名审核、角色权限管理 + +### 🕶️ AR/3D 展示 +- **3D模型预览**:基于Three.js的交互式3D模型展示 +- **AR交互体验**:增强现实功能集成 +- **多媒体支持**:图片、视频、文件等多格式媒体处理 + +### 📱 多端适配 +- **微信小程序**:Taro框架开发、原生小程序体验、分包优化 +- **Web管理端**:React + Ant Design、响应式设计、管理后台功能 +- **跨平台支持**:可扩展至H5、支付宝小程序等平台 + +### 🔒 安全认证 +- **微信登录**:小程序code换取session、OpenID/UnionID管理 +- **手机验证**:验证码登录、手机号绑定、用户合并机制 +- **JWT认证**:Token-based身份验证、API访问控制 +- **权限验证**:基于角色的访问控制、操作权限验证 + +## 🛠️ 技术栈与依赖 + +### Backend (后端) +- **Framework**: Django 6.0 + Django REST Framework 3.16 +- **Database**: PostgreSQL (psycopg2) +- **Payment**: WeChat Pay V3 (wechatpayv3) +- **AI Services**: 阿里云听悟 (语音转写)、通义千问 (AI评估) +- **Cloud Storage**: 阿里云OSS (文件存储) +- **Documentation**: drf-spectacular (OpenAPI 3.0) +- **Deployment**: Docker, Gunicorn +- **Authentication**: JWT + 微信OAuth2.0 + +### Frontend (Web 端) +- **Core**: React 19 + Vite 7 +- **UI Library**: Ant Design 6 +- **3D Engine**: Three.js + @react-three/fiber +- **Routing**: React Router v7 + +### Miniprogram (小程序) +- **Framework**: Taro 3.6 (React Flavor) +- **UI Library**: Taro UI +- **Styles**: SCSS +- **Platform**: WeChat Mini Program (可扩展至 H5/Alipay 等) + +## 🚀 本地开发环境搭建 + +### 1. 系统要求 +- **Node.js**: >= 18.0.0 +- **Python**: >= 3.10 +- **PostgreSQL**: >= 13 +- **WeChat DevTools**: 最新版 (用于小程序开发) + +### 2. 克隆仓库 +```bash +git clone +cd market_page +``` + +### 3. 后端环境配置 (Backend) +```bash +cd backend + +# 创建虚拟环境 (推荐) +python -m venv venv +# Windows 激活 +venv\Scripts\activate +# macOS/Linux 激活 +source venv/bin/activate + +# 安装依赖 +pip install -r requirements.txt + +# 数据库迁移 +python manage.py migrate + +# 创建超级用户 +python manage.py createsuperuser + +# 启动开发服务器 (默认端口 8000) +python manage.py runserver +``` + +### 4. Web 前端配置 (Frontend) +```bash +cd ../frontend + +# 安装依赖 +npm install + +# 启动开发服务器 (默认端口 5173) +npm run dev +``` + +### 5. 小程序配置 (Miniprogram) +```bash +cd ../miniprogram + +# 安装依赖 +npm install + +# 编译并监听 (微信小程序) +npm run dev:weapp +``` +*启动后,请打开微信开发者工具,导入 `miniprogram` 目录进行预览。* + +## 📦 构建与运行 + +### Backend +```bash +# 收集静态文件 +python manage.py collectstatic --noinput + +# 使用 Gunicorn 运行 (生产环境) +gunicorn config.wsgi:application --bind 0.0.0.0:8000 +``` + +### Frontend +```bash +# 构建生产版本 +npm run build + +# 预览构建产物 +npm run preview +``` + +### Miniprogram +```bash +# 构建生产版本 (微信小程序) +npm run build:weapp +``` + +## 🧪 测试与覆盖率 + +### Backend +```bash +# 运行所有测试 +python manage.py test + +# 运行特定模块测试 +python manage.py test shop.tests +``` + +### Frontend / Miniprogram +```bash +# 代码风格检查 +npm run lint +``` + +## 🚢 部署指南 + +### Docker 部署 (推荐) +项目包含 `Dockerfile` 和 `docker-compose.yml` (根目录下),可一键启动。 + +```bash +# 在项目根目录 +docker-compose up -d --build +``` +*注意:请确保已在 `backend/config/settings.py` 或环境变量中配置好生产环境的数据库连接和密钥。* + +## 🔌 API 接口示例 + +后端提供 RESTful API,以下为核心接口示例: + +| 方法 | 路径 | 描述 | +| --- | --- | --- | +| POST | `/api/shop/wechat/login/` | 微信用户登录 (换取 JWT) | +| GET | `/api/shop/configs/` | 获取 ESP32/商品配置列表 | +| POST | `/api/shop/orders/` | 创建新订单 | +| POST | `/api/shop/pay/` | 发起微信支付 | +| GET | `/api/community/topics/` | 获取论坛话题列表 | +| POST | `/api/ai/transcription/` | 创建语音转写任务 | +| GET | `/api/ai/transcription/{id}/` | 获取转写任务状态 | +| POST | `/api/competition/projects/` | 提交参赛项目 | +| GET | `/api/competition/projects/{id}/score/` | 获取项目评分 | +| POST | `/api/competition/scoring/` | 评委提交评分 | + +**API 文档**: 启动后端后访问 `http://localhost:8000/api/schema/swagger-ui/` 查看完整 Swagger 文档。 + +## 📂 目录结构说明 + +``` +market_page/ +├── backend/ # Django 后端源码 +│ ├── ai_services/ # AI服务模块 (语音转写、AI评估) +│ │ ├── models.py # 转写任务、AI评估模板模型 +│ │ ├── views.py # API接口 (转写、评估、总结) +│ │ └── services.py # 阿里云听悟、通义千问服务集成 +│ ├── community/ # 论坛社区模块 +│ │ ├── models.py # 活动、帖子、回复、公告模型 +│ │ ├── views.py # 社区API接口 +│ │ └── admin_actions.py # 后台管理动作 +│ ├── competition/ # 竞赛评审模块 +│ │ ├── models.py # 比赛、项目、评分、维度模型 +│ │ ├── judge_views.py # 评委系统接口 +│ │ └── templates/ # 评委系统前端页面 +│ ├── shop/ # 电商与支付模块 +│ │ ├── models.py # 商品、订单、支付、用户模型 +│ │ ├── services.py # 微信支付、短信服务 +│ │ └── admin_actions.py # 订单管理动作 +│ ├── config/ # 项目核心配置 +│ │ ├── settings.py # Django配置 +│ │ └── urls.py # 主路由配置 +│ ├── uploads/ # 用户上传文件 (媒体资源) +│ ├── manage.py # Django 管理脚本 +│ ├── requirements.txt # Python 依赖 +│ └── Dockerfile # 后端容器配置 +├── frontend/ # React Web 端源码 +│ ├── src/ +│ │ ├── components/ # 公共组件 (3D模型、弹窗等) +│ │ ├── pages/ # 页面路由 (Home, Forum, Payment) +│ │ ├── hooks/ # 自定义React Hooks +│ │ └── assets/ # 静态资源 +│ ├── public/ # 公共资源 +│ └── vite.config.js # Vite 配置 +├── miniprogram/ # Taro 小程序源码 +│ ├── src/ +│ │ ├── pages/ # 小程序页面 +│ │ ├── subpackages/ # 分包页面 (分销、论坛详情等) +│ │ ├── components/ # 小程序组件 +│ │ └── utils/ # 工具函数 +│ └── project.config.json # 微信小程序配置 +├── docker-compose.yml # Docker 编排文件 +└── README.md # 项目文档 +``` + +## 🤝 贡献规范 + +欢迎提交 Pull Request!请遵循以下规范: + +1. **分支管理**: + - `main`: 主分支,保持稳定。 + - `dev`: 开发分支。 + - `feat/xxx`: 新功能分支。 + - `fix/xxx`: Bug 修复分支。 + +2. **Commit 格式**: + - `feat: 添加购物车功能` + - `fix: 修复支付回调失败问题` + - `docs: 更新 README` + - `style: 调整首页样式` + +3. **PR 流程**: + - Fork 本仓库。 + - 创建特性分支。 + - 提交代码并推送到您的 Fork。 + - 提交 PR 至 `dev` 分支。 + +## ❓ 常见问题排查 + +- **Q: 后端启动报错 `psycopg2` 相关错误?** + - A: 请确保本地已安装 PostgreSQL 并且开发库 (`libpq-dev` 或 equivalent) 已就绪。 + +- **Q: 小程序报错 "appID 不合法"?** + - A: 请在 `miniprogram/project.config.json` 中修改 `appid` 为您自己的测试 ID,或在开发者工具中开启 "不校验合法域名"。 + +- **Q: 微信支付接口调用失败?** + - A: 微信支付依赖真实商户号和证书,本地开发请使用模拟数据或沙箱环境。 + +- **Q: AI语音转写任务状态一直显示"处理中"?** + - A: 检查阿里云听悟服务配置是否正确,包括AccessKey、AppKey等参数。可通过`python manage.py check_aliyun_config`命令验证配置。 + +- **Q: AI评估功能无法正常使用?** + - A: 确保通义千问API密钥已正确配置,检查模型调用配额是否充足。评估模板中的提示词需要符合模型要求。 + +- **Q: 分销佣金没有正确计算?** + - A: 检查产品是否设置了独立分润比例,确认分销员状态为"正常",查看佣金日志了解具体计算过程。 + +- **Q: 竞赛项目无法提交?** + - A: 确认比赛状态为"作品提交中",检查是否已报名该比赛,确保每人每比赛只能提交一个项目。 + +## 📜 许可证 + +本项目采用 [MIT License](LICENSE) 许可证。 + +## 📧 联系方式 + +- **作者**: (Your Name/Organization) +- **邮箱**: contact@example.com +- **项目主页**: https://github.com/yourusername/market-page diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..467310f --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,9 @@ +# Aliyun OSS Configuration +ALIYUN_ACCESS_KEY_ID=LTAI5tE62GW8MKyoEaotzxXk +ALIYUN_ACCESS_KEY_SECRET=Zdzqo1fgj57DxxioXOotNKhJdSfVQW +ALIYUN_OSS_ENDPOINT=https://oss-cn-shanghai.aliyuncs.com +ALIYUN_OSS_BUCKET_NAME=tangledup-ai-staging +ALIYUN_OSS_INTERNAL_ENDPOINT=https://oss-cn-shanghai-internal.aliyuncs.com + +# Aliyun Tingwu Configuration +ALIYUN_TINGWU_APP_KEY=6eOX7N3tKE0fDwb diff --git a/backend/DEPLOY.md b/backend/DEPLOY.md new file mode 100644 index 0000000..3b656fe --- /dev/null +++ b/backend/DEPLOY.md @@ -0,0 +1,54 @@ +# 评委端系统部署说明 + +## 1. 系统概述 +本系统为基于 Django 的后端渲染 HTML 评委端,提供评委登录、项目查看、打分点评、音频上传与 AI 服务管理功能。 + +## 2. 依赖环境 +- Python 3.8+ +- Django 3.2+ +- Aliyun SDK (aliyun-python-sdk-core, aliyun-python-sdk-tingwu, oss2) +- requests + +确保 `requirements.txt` 中包含以上依赖。 + +## 3. 环境变量 +系统依赖以下环境变量(在 `backend/config/settings.py` 或 `.env` 文件中配置): + +```bash +# 数据库配置 +DB_NAME=your_db_name +DB_USER=your_db_user +DB_PASSWORD=your_db_password +DB_HOST=your_db_host +DB_PORT=5432 + +# 阿里云配置 (用于音频上传与 AI 服务) +ALIYUN_ACCESS_KEY_ID=your_access_key_id +ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret +ALIYUN_OSS_BUCKET_NAME=your_bucket_name +ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com +ALIYUN_TINGWU_APP_KEY=your_tingwu_app_key +``` + +## 4. 启动脚本 +使用提供的 `start_judge_system.sh` 启动服务。 + +```bash +chmod +x start_judge_system.sh +./start_judge_system.sh +``` + +该脚本将执行数据库迁移并启动 Django 开发服务器。生产环境建议使用 Gunicorn + Nginx。 + +## 5. 访问地址 +- 评委端入口: `http://localhost:8000/competition/admin/` (自动跳转至登录或仪表盘) +- 评委端主页: `http://localhost:8000/judge/dashboard/` +- AI 管理页: `http://localhost:8000/judge/ai/manage/` + +## 6. 审计日志 +所有关键操作(登录、打分、上传、删除)均记录在项目根目录下的 `judge_audit.log` 文件中。格式如下: +`[YYYY-MM-DD HH:MM:SS] IP:127.0.0.1 | Phone:13800000000 | Action:LOGIN | Target:System | Result:SUCCESS | Details:...` + +## 7. 注意事项 +- 登录需使用已在后台绑定且角色为“评委”的手机号。 +- 验证码在开发模式下通过控制台输出,或使用默认测试码 `8888`。 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..4b0bd1f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,26 @@ +# Use an official Python runtime as a parent image +FROM python:3.13-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Set work directory +WORKDIR /app + +# Install python dependencies +COPY requirements.txt /app/ +RUN pip install --upgrade pip && pip install -r requirements.txt + +# Copy project +COPY . /app/ +COPY .env /app/ + +# Expose port +EXPOSE 8000 + +# Volume for media files +VOLUME ["/app/media"] + +# Run the application with gunicorn +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "config.wsgi:application"] diff --git a/backend/TEST_REPORT.md b/backend/TEST_REPORT.md new file mode 100644 index 0000000..9f90276 --- /dev/null +++ b/backend/TEST_REPORT.md @@ -0,0 +1,44 @@ +# 评委端系统测试报告 + +## 1. 测试环境 +- 系统版本: MacOS 14.5 +- Python: 3.9 +- Django: 3.2.20 +- 数据库: PostgreSQL / SQLite (Development) + +## 2. 功能测试 + +### 2.1 评委登录 +- **场景**: 输入已绑定评委角色的手机号。 +- **操作**: 点击“发送验证码”,输入控制台显示的验证码或默认测试码 `8888`。 +- **结果**: 成功登录,跳转至 `/judge/dashboard/`。 +- **异常场景**: 输入未绑定手机号、输入错误验证码,均提示相应错误信息。 + +### 2.2 项目列表 (仪表盘) +- **场景**: 登录后查看所负责比赛的项目。 +- **结果**: 列表展示正确,包含封面、选手名、当前状态。点击“详情 & 评分”弹出模态框。 + +### 2.3 评分与点评 +- **场景**: 在详情模态框中调整评分滑块,输入评语,点击提交。 +- **结果**: 页面提示“已保存”,刷新后数据持久化。 +- **审计日志**: `judge_audit.log` 记录 `SCORE_UPDATE` 操作。 + +### 2.4 音频上传 +- **场景**: 点击“批量上传音频”,选择 MP3/MP4 文件,关联项目。 +- **结果**: 进度条显示上传进度,完成后自动跳转至 AI 管理页面。 +- **审计日志**: `judge_audit.log` 记录 `UPLOAD_AUDIO` 操作。 + +### 2.5 AI 服务管理 +- **场景**: 在 AI 管理页面查看任务状态。 +- **操作**: 点击“刷新状态”,如果任务完成,状态变更为“成功”,并可查看结果。 +- **结果**: 成功展示 AI 生成的逐字稿、总结和评分。 +- **删除操作**: 点击“删除”,确认后记录消失,审计日志记录 `DELETE_TASK`。 + +## 3. 性能与兼容性 +- **响应式**: 在 iPhone/iPad 模拟器下布局自适应,操作流畅。 +- **并发**: 批量上传 5 个文件,均能正常创建任务并返回。 + +## 4. 安全性 +- **权限控制**: 尝试访问非本人负责项目的详情 API,返回 403 Forbidden。 +- **Session**: 登出后 Session 清除,无法通过 URL 直接访问受保护页面。 +- **CSRF**: 所有 POST 请求均携带 CSRF Token。 diff --git a/backend/ai_services/__init__.py b/backend/ai_services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/ai_services/admin.py b/backend/ai_services/admin.py new file mode 100644 index 0000000..421a97b --- /dev/null +++ b/backend/ai_services/admin.py @@ -0,0 +1,47 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin as UnfoldModelAdmin +from unfold.admin import StackedInline as UnfoldStackedInline +from .models import TranscriptionTask, AIEvaluation, AIEvaluationTemplate + +class AIEvaluationInline(UnfoldStackedInline): + model = AIEvaluation + extra = 0 + can_delete = True + verbose_name = "AI评估结果" + verbose_name_plural = "AI评估结果" + readonly_fields = ['created_at', 'updated_at', 'raw_response', 'reasoning', 'template'] + fields = ('template', 'score', 'evaluation', 'model_selection', 'prompt', 'reasoning', 'status', 'error_message') + +@admin.register(TranscriptionTask) +class TranscriptionTaskAdmin(UnfoldModelAdmin): + list_display = ['id', 'status', 'task_id', 'created_at'] + list_filter = ['status', 'created_at'] + search_fields = ['id', 'task_id', 'transcription', 'summary'] + readonly_fields = ['id', 'created_at', 'updated_at', 'task_id'] + inlines = [AIEvaluationInline] + +@admin.register(AIEvaluationTemplate) +class AIEvaluationTemplateAdmin(UnfoldModelAdmin): + list_display = ['name', 'model_selection', 'score_dimension', 'is_default', 'is_active', 'created_at'] + list_filter = ['is_active', 'is_default', 'model_selection', 'created_at'] + search_fields = ['name', 'prompt'] + +@admin.register(AIEvaluation) +class AIEvaluationAdmin(UnfoldModelAdmin): + list_display = ['id', 'task', 'template', 'score', 'status', 'model_selection', 'created_at'] + list_filter = ['status', 'model_selection', 'created_at', 'template'] + search_fields = ['task__id', 'evaluation', 'reasoning'] + readonly_fields = ['id', 'created_at', 'updated_at', 'raw_response'] + fieldsets = ( + (None, { + 'fields': ('task', 'template', 'status', 'score', 'evaluation') + }), + ('配置快照', { + 'fields': ('model_selection', 'prompt'), + 'classes': ('collapse',), + }), + ('调试信息', { + 'fields': ('raw_response', 'reasoning', 'error_message'), + 'classes': ('collapse',), + }), + ) diff --git a/backend/ai_services/apps.py b/backend/ai_services/apps.py new file mode 100644 index 0000000..cce3cbc --- /dev/null +++ b/backend/ai_services/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AiServicesConfig(AppConfig): + name = 'ai_services' diff --git a/backend/ai_services/bailian_service.py b/backend/ai_services/bailian_service.py new file mode 100644 index 0000000..73a113b --- /dev/null +++ b/backend/ai_services/bailian_service.py @@ -0,0 +1,323 @@ +import logging +import json +import os +from django.conf import settings +from openai import OpenAI +from .models import AIEvaluation + +logger = logging.getLogger(__name__) + +class BailianService: + def __init__(self): + self.api_key = getattr(settings, 'DASHSCOPE_API_KEY', None) + if not self.api_key: + self.api_key = os.environ.get("DASHSCOPE_API_KEY") + + if self.api_key: + self.client = OpenAI( + api_key=self.api_key, + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1" + ) + else: + self.client = None + logger.warning("DASHSCOPE_API_KEY not configured.") + + def evaluate_task(self, evaluation: AIEvaluation): + """ + 执行AI评估 + """ + if not self.client: + evaluation.status = AIEvaluation.Status.FAILED + evaluation.error_message = "服务未配置 (DASHSCOPE_API_KEY missing)" + evaluation.save() + return + + task = evaluation.task + if not task.transcription: + evaluation.status = AIEvaluation.Status.FAILED + evaluation.error_message = "关联任务无逐字稿内容" + evaluation.save() + return + + evaluation.status = AIEvaluation.Status.PROCESSING + evaluation.save() + + try: + prompt = evaluation.prompt + content = task.transcription + + # 准备章节/时间戳数据以辅助分析发言节奏 + chapter_context = "" + if task.auto_chapters_data: + try: + chapters_str = "" + # 处理特定的 AutoChapters 结构 + # 格式: {"AutoChapters": [{"Id": 1, "Start": 740, "End": 203436, "Headline": "...", "Summary": "..."}, ...]} + if isinstance(task.auto_chapters_data, dict) and 'AutoChapters' in task.auto_chapters_data: + chapters = task.auto_chapters_data['AutoChapters'] + if isinstance(chapters, list): + chapter_lines = [] + for ch in chapters: + # 毫秒转 MM:SS + start_ms = ch.get('Start', 0) + end_ms = ch.get('End', 0) + start_str = f"{start_ms // 60000:02d}:{(start_ms // 1000) % 60:02d}" + end_str = f"{end_ms // 60000:02d}:{(end_ms // 1000) % 60:02d}" + + headline = ch.get('Headline', '无标题') + summary = ch.get('Summary', '') + + line = f"- [{start_str} - {end_str}] {headline}" + if summary: + line += f"\n 摘要: {summary}" + chapter_lines.append(line) + + chapters_str = "\n".join(chapter_lines) + + # 如果上面的解析为空(或者格式不匹配),回退到通用 JSON dump + if not chapters_str: + if isinstance(task.auto_chapters_data, (dict, list)): + chapters_str = json.dumps(task.auto_chapters_data, ensure_ascii=False, indent=2) + else: + chapters_str = str(task.auto_chapters_data) + + chapter_context = f"\n\n【章节与时间戳信息】\n{chapters_str}\n\n(提示:请结合上述章节时间戳信息,分析发言者的语速、节奏变化及停顿情况。)" + except Exception as e: + logger.warning(f"Failed to process auto_chapters_data: {e}") + + # 截断过长的内容以防止超出Token限制 (简单处理,取前10000字) + if len(content) > 10000: + content = content[:10000] + "...(内容过长已截断)" + + # Construct messages + messages = [ + {'role': 'system', 'content': 'You are a helpful assistant designed to output JSON.'}, + {'role': 'user', 'content': f"{prompt}\n\n以下是需要评估的内容:\n{content}{chapter_context}"} + ] + + # 增加重试机制 (最多重试3次) + completion = None + last_error = None + import time + + for attempt in range(3): + try: + completion = self.client.chat.completions.create( + model=evaluation.model_selection, + messages=messages, + response_format={"type": "json_object"} + ) + break # 成功则跳出循环 + except Exception as e: + last_error = e + logger.warning(f"AI Evaluation attempt {attempt+1}/3 failed for eval {evaluation.id}: {e}") + if attempt < 2: + time.sleep(2 * (attempt + 1)) # 简单的指数退避 + + if not completion: + raise last_error or Exception("AI Service call failed after retries") + + response_content = completion.choices[0].message.content + # Convert to dict for storage + raw_response = completion.model_dump() + + evaluation.raw_response = raw_response + + # Parse JSON + try: + result = json.loads(response_content) + evaluation.score = result.get('score') + evaluation.evaluation = result.get('evaluation') or result.get('comment') + + # 尝试获取推理过程(如果模型返回了) + evaluation.reasoning = result.get('reasoning') or result.get('analysis') + + if not evaluation.reasoning: + # 如果JSON里没有,把整个JSON作为推理参考 + evaluation.reasoning = json.dumps(result, ensure_ascii=False, indent=2) + + evaluation.status = AIEvaluation.Status.COMPLETED + except json.JSONDecodeError: + evaluation.status = AIEvaluation.Status.FAILED + evaluation.error_message = f"无法解析JSON响应: {response_content}" + evaluation.reasoning = response_content + + evaluation.save() + + # 同步结果到参赛项目 (如果关联了) + self._sync_evaluation_to_project(evaluation) + + return evaluation + + except Exception as e: + logger.error(f"AI Evaluation failed: {e}") + evaluation.status = AIEvaluation.Status.FAILED + evaluation.error_message = str(e) + evaluation.save() + return evaluation + + def _sync_evaluation_to_project(self, evaluation: AIEvaluation): + """ + 将AI评估结果同步到关联的参赛项目(评分和评语) + """ + try: + task = evaluation.task + if not task.project: + return + + project = task.project + competition = project.competition + + # 1. 确定评委身份 (Based on Template) + # 用户要求:评委显示的是模板名称 + template_name = evaluation.template.name if evaluation.template else "AI智能评委" + # 使用固定前缀 + template_id 确保唯一性,这样同一个模板在不同项目里是同一个评委 + openid = f"ai_judge_{evaluation.template.id}" if evaluation.template else "ai_judge_default" + + # 延迟导入以避免循环依赖 + from shop.models import WeChatUser + from competition.models import CompetitionEnrollment, Score, Comment, ScoreDimension + + # 获取或创建虚拟评委用户 + user, created = WeChatUser.objects.get_or_create( + openid=openid, + defaults={ + 'nickname': template_name, + 'avatar_url': 'https://ui-avatars.com/api/?name=AI&background=random&color=fff' + } + ) + + # 如果名字不匹配(比如模板改名了),更新它 + if user.nickname != template_name: + user.nickname = template_name + user.save(update_fields=['nickname']) + + # 2. 确保评委已报名 (Enrollment) + enrollment, _ = CompetitionEnrollment.objects.get_or_create( + competition=competition, + user=user, + defaults={ + 'role': 'judge', + 'status': 'approved' + } + ) + + # 3. 同步评分 (Score) + if evaluation.score is not None: + # 尝试找到匹配的维度 + dimensions = competition.score_dimensions.all() + target_dimension = None + + # 0. 优先使用模板配置的维度 + if evaluation.template and evaluation.template.score_dimension: + # 检查配置的维度是否属于当前比赛 + if evaluation.template.score_dimension.competition_id == competition.id: + target_dimension = evaluation.template.score_dimension + else: + # 如果不属于当前比赛(跨比赛复用模板),尝试查找同名维度 + target_dimension = dimensions.filter(name=evaluation.template.score_dimension.name).first() + + # 1. 如果未配置或未找到,尝试匹配 "AI Rating" (用户指定默认值) + if not target_dimension: + target_dimension = dimensions.filter(name__iexact="AI Rating").first() + + # 2. 尝试匹配包含 "AI" 的维度 + if not target_dimension: + for dim in dimensions: + if "AI" in dim.name.upper(): + target_dimension = dim + break + + # 3. 尝试匹配模板名称 + if not target_dimension: + target_dimension = dimensions.filter(name=template_name).first() + + # 4. 最后兜底:使用第一个维度 + if not target_dimension and dimensions.exists(): + target_dimension = dimensions.first() + + if target_dimension: + Score.objects.update_or_create( + project=project, + judge=enrollment, + dimension=target_dimension, + defaults={'score': evaluation.score} + ) + logger.info(f"Synced AI score {evaluation.score} to project {project.id} dimension {target_dimension.name}") + + # 4. 同步评语 (Comment) + if evaluation.evaluation: + # 检查是否已存在该评委的评语,避免重复 + comment = Comment.objects.filter(project=project, judge=enrollment).first() + if comment: + comment.content = evaluation.evaluation + comment.save() + else: + Comment.objects.create( + project=project, + judge=enrollment, + content=evaluation.evaluation + ) + logger.info(f"Synced AI comment to project {project.id}") + + except Exception as e: + logger.error(f"Failed to sync evaluation to project: {e}") + + def summarize_task(self, task): + """ + 对转写任务进行总结 + """ + if not self.client: + logger.warning("BailianService not initialized, skipping summary.") + return + + if not task.transcription: + logger.warning(f"Task {task.id} has no transcription, skipping summary.") + return + + try: + content = task.transcription + # 简单截断防止过长 + if len(content) > 15000: + content = content[:15000] + "...(内容过长已截断)" + + # 准备上下文数据 + context_data = "" + if task.summary_data: + context_data += f"\n\n【总结原始数据】\n{json.dumps(task.summary_data, ensure_ascii=False, indent=2)}" + + if task.auto_chapters_data: + context_data += f"\n\n【章节原始数据】\n{json.dumps(task.auto_chapters_data, ensure_ascii=False, indent=2)}" + + system_prompt = f"""你是一个专业的会议/内容总结助手。请根据提供的【转写文本】、【总结原始数据】和【章节原始数据】,生成一份结构清晰、内容详实的总结报告。 + +请按照以下结构输出(Markdown格式): +1. **标题**:基于内容生成一个合适的标题。 +2. **核心摘要**:简要概括主要内容。 +3. **主要观点/话题**:结合思维导图数据,列出关键话题和层级。 +4. **章节速览**:结合章节数据,列出时间点和主要内容。[HH:MM:SS]格式来把章节列出来 +5. **问答精选**(如果有):基于问答总结数据,列出重要问答。 + +请确保语言通顺,重点突出,能够还原内容的逻辑结构。""" + + user_content = f"以下是需要总结的内容:\n\n【转写文本】\n{content}{context_data}" + + messages = [ + {'role': 'system', 'content': system_prompt}, + {'role': 'user', 'content': user_content} + ] + + # 使用 qwen-plus 作为默认模型 + completion = self.client.chat.completions.create( + model="qwen-plus", + messages=messages + ) + + summary_content = completion.choices[0].message.content + task.summary = summary_content + task.save(update_fields=['summary']) + + logger.info(f"Task {task.id} summary generated successfully.") + + except Exception as e: + logger.error(f"Failed to generate summary for task {task.id}: {e}") diff --git a/backend/ai_services/management/__init__.py b/backend/ai_services/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/ai_services/management/commands/__init__.py b/backend/ai_services/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/ai_services/management/commands/check_aliyun_config.py b/backend/ai_services/management/commands/check_aliyun_config.py new file mode 100644 index 0000000..3541afe --- /dev/null +++ b/backend/ai_services/management/commands/check_aliyun_config.py @@ -0,0 +1,54 @@ +from django.core.management.base import BaseCommand +from django.conf import settings +import oss2 +from aliyunsdkcore.client import AcsClient + +class Command(BaseCommand): + help = 'Check Aliyun configuration status' + + def handle(self, *args, **options): + self.stdout.write("Checking Aliyun Configuration...") + + configs = { + 'ALIYUN_ACCESS_KEY_ID': settings.ALIYUN_ACCESS_KEY_ID, + 'ALIYUN_ACCESS_KEY_SECRET': settings.ALIYUN_ACCESS_KEY_SECRET, + 'ALIYUN_OSS_BUCKET_NAME': settings.ALIYUN_OSS_BUCKET_NAME, + 'ALIYUN_OSS_ENDPOINT': settings.ALIYUN_OSS_ENDPOINT, + 'ALIYUN_TINGWU_APP_KEY': settings.ALIYUN_TINGWU_APP_KEY, + } + + all_valid = True + for key, value in configs.items(): + if not value: + self.stdout.write(self.style.ERROR(f"[MISSING] {key} is not set or empty")) + all_valid = False + else: + masked_value = value[:4] + "****" + value[-4:] if len(value) > 8 else "****" + self.stdout.write(self.style.SUCCESS(f"[OK] {key}: {masked_value}")) + + if not all_valid: + self.stdout.write(self.style.ERROR("\nConfiguration check FAILED. Some required settings are missing.")) + return + + # Test OSS Connection + self.stdout.write("\nTesting OSS Connection...") + try: + auth = oss2.Auth(configs['ALIYUN_ACCESS_KEY_ID'], configs['ALIYUN_ACCESS_KEY_SECRET']) + bucket = oss2.Bucket(auth, configs['ALIYUN_OSS_ENDPOINT'], configs['ALIYUN_OSS_BUCKET_NAME']) + bucket.get_bucket_info() + self.stdout.write(self.style.SUCCESS("[OK] OSS Connection successful")) + except Exception as e: + self.stdout.write(self.style.ERROR(f"[FAILED] OSS Connection failed: {e}")) + + # Test Tingwu Client Initialization + self.stdout.write("\nTesting Tingwu Client Initialization...") + try: + client = AcsClient( + configs['ALIYUN_ACCESS_KEY_ID'], + configs['ALIYUN_ACCESS_KEY_SECRET'], + "cn-beijing" + ) + self.stdout.write(self.style.SUCCESS("[OK] Tingwu Client initialized")) + except Exception as e: + self.stdout.write(self.style.ERROR(f"[FAILED] Tingwu Client init failed: {e}")) + diff --git a/backend/ai_services/management/commands/poll_transcription_results.py b/backend/ai_services/management/commands/poll_transcription_results.py new file mode 100644 index 0000000..16d2620 --- /dev/null +++ b/backend/ai_services/management/commands/poll_transcription_results.py @@ -0,0 +1,63 @@ +import time +import logging +from django.core.management.base import BaseCommand +from ai_services.models import TranscriptionTask +from ai_services.services import AliyunTingwuService + +logger = logging.getLogger(__name__) + +class Command(BaseCommand): + help = 'Polls Aliyun Tingwu for transcription results every 10 seconds' + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('Starting polling service...')) + service = AliyunTingwuService() + + while True: + try: + # Find tasks that are PENDING or PROCESSING + # Include PENDING because create() might set it to PENDING initially + # though usually it sets to PROCESSING if task_id is obtained. + # Just in case. + tasks = TranscriptionTask.objects.filter( + status__in=[TranscriptionTask.Status.PENDING, TranscriptionTask.Status.PROCESSING] + ).exclude(task_id__isnull=True).exclude(task_id='') + + count = tasks.count() + if count > 0: + self.stdout.write(f'Found {count} pending/processing tasks.') + + for task in tasks: + self.stdout.write(f'Checking task {task.task_id} (Status: {task.status})...') + try: + result = service.get_task_info(task.task_id) + + # Store old status to check for changes + old_status = task.status + + service.parse_and_update_task(task, result) + + # Re-fetch or check updated object + if task.status != old_status: + if task.status == TranscriptionTask.Status.SUCCEEDED: + self.stdout.write(self.style.SUCCESS(f'Task {task.task_id} SUCCEEDED')) + elif task.status == TranscriptionTask.Status.FAILED: + self.stdout.write(self.style.ERROR(f'Task {task.task_id} FAILED: {task.error_message}')) + else: + # Still processing + pass + + except Exception as e: + logger.error(f"Error checking task {task.task_id}: {e}") + self.stdout.write(self.style.ERROR(f"Error checking task {task.task_id}: {e}")) + + # Wait for 10 seconds + time.sleep(10) + + except KeyboardInterrupt: + self.stdout.write(self.style.SUCCESS('Stopping polling service...')) + break + except Exception as e: + logger.error(f"Polling loop error: {e}") + self.stdout.write(self.style.ERROR(f"Polling loop error: {e}")) + time.sleep(10) diff --git a/backend/ai_services/management/commands/test_tingwu_local.py b/backend/ai_services/management/commands/test_tingwu_local.py new file mode 100644 index 0000000..1c195f6 --- /dev/null +++ b/backend/ai_services/management/commands/test_tingwu_local.py @@ -0,0 +1,102 @@ +import os +import sys +import django +import json +import logging +from django.conf import settings + +# 设置 Django 环境 +# 添加项目根目录到 sys.path +sys.path.append('/Volumes/data/Quant-Speed/market_page/backend') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') # 修正为正确的 settings 模块路径 +django.setup() + +from ai_services.services import AliyunTingwuService +from ai_services.models import TranscriptionTask + +# 配置日志 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def test_tingwu_transcription(): + file_url = "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/Video/%E6%95%99%E5%AD%A6.mp4" + + print(f"Testing transcription for: {file_url}") + + service = AliyunTingwuService() + + # 1. 创建任务 + try: + print("Creating task...") + response = service.create_transcription_task(file_url) + print(f"Create task response: {json.dumps(response, indent=2, ensure_ascii=False)}") + + if 'Data' in response and isinstance(response['Data'], dict): + task_id = response['Data'].get('TaskId') + else: + task_id = response.get('TaskId') + + if not task_id: + print("Failed to get TaskId") + return + + print(f"Task created with ID: {task_id}") + + # 2. 轮询查询任务状态 + import time + max_retries = 60 # 5 minutes + for i in range(max_retries): + print(f"Checking status (attempt {i+1}/{max_retries})...") + result = service.get_task_info(task_id) + + # 解析结果 + if isinstance(result, str): + try: + result = json.loads(result) + except: + pass + + if isinstance(result, dict): + data_obj = result.get('Data', result) + else: + data_obj = result + + task_status = data_obj.get('TaskStatus') + if not task_status: + task_status = data_obj.get('Status') + + print(f"Current status: {task_status}") + + if task_status in ['COMPLETE', 'COMPLETED', 'SUCCEEDED']: + print("Task succeeded!") + print(f"Full Result: {json.dumps(data_obj, indent=2, ensure_ascii=False)}") + + # 尝试解析 Transcription + task_result = data_obj.get('Result', {}) + transcription_data = task_result.get('Transcription', {}) + + if isinstance(transcription_data, str) and transcription_data.startswith('http'): + import requests + print(f"Downloading transcription from {transcription_data}") + t_resp = requests.get(transcription_data) + if t_resp.status_code == 200: + content = t_resp.json() + print(f"Downloaded content structure keys: {content.keys()}") + # print(f"Content sample: {json.dumps(content, indent=2, ensure_ascii=False)[:500]}...") + else: + print(f"Failed to download: {t_resp.status_code}") + + break + elif task_status == 'FAILED': + print(f"Task failed: {data_obj}") + break + + time.sleep(5) + + except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + test_tingwu_transcription() diff --git a/backend/ai_services/migrations/0001_initial.py b/backend/ai_services/migrations/0001_initial.py new file mode 100644 index 0000000..b0b63db --- /dev/null +++ b/backend/ai_services/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.1 on 2026-03-11 05:11 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='TranscriptionTask', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file_url', models.URLField(max_length=1024, verbose_name='文件链接')), + ('task_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='听悟任务ID')), + ('status', models.CharField(choices=[('PENDING', '等待中'), ('PROCESSING', '处理中'), ('SUCCEEDED', '成功'), ('FAILED', '失败')], default='PENDING', max_length=20, verbose_name='状态')), + ('transcription', models.TextField(blank=True, null=True, verbose_name='逐字稿')), + ('summary', models.TextField(blank=True, null=True, verbose_name='AI总结')), + ('error_message', models.TextField(blank=True, null=True, verbose_name='错误信息')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '转写任务', + 'verbose_name_plural': '转写任务', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/ai_services/migrations/0002_transcriptiontask_evaluation_transcriptiontask_score.py b/backend/ai_services/migrations/0002_transcriptiontask_evaluation_transcriptiontask_score.py new file mode 100644 index 0000000..63b9dbf --- /dev/null +++ b/backend/ai_services/migrations/0002_transcriptiontask_evaluation_transcriptiontask_score.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-03-11 05:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai_services', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='transcriptiontask', + name='evaluation', + field=models.TextField(blank=True, null=True, verbose_name='AI评语'), + ), + migrations.AddField( + model_name='transcriptiontask', + name='score', + field=models.IntegerField(blank=True, help_text='基于转写内容的评分', null=True, verbose_name='AI评分'), + ), + ] diff --git a/backend/ai_services/migrations/0003_transcriptiontask_auto_chapters_data_and_more.py b/backend/ai_services/migrations/0003_transcriptiontask_auto_chapters_data_and_more.py new file mode 100644 index 0000000..af3798d --- /dev/null +++ b/backend/ai_services/migrations/0003_transcriptiontask_auto_chapters_data_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.1 on 2026-03-11 12:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai_services', '0002_transcriptiontask_evaluation_transcriptiontask_score'), + ] + + operations = [ + migrations.AddField( + model_name='transcriptiontask', + name='auto_chapters_data', + field=models.JSONField(blank=True, help_text='阿里云返回的AutoChapters完整JSON', null=True, verbose_name='章节原始数据'), + ), + migrations.AddField( + model_name='transcriptiontask', + name='summary_data', + field=models.JSONField(blank=True, help_text='阿里云返回的Summarization完整JSON', null=True, verbose_name='总结原始数据'), + ), + migrations.AddField( + model_name='transcriptiontask', + name='transcription_data', + field=models.JSONField(blank=True, help_text='阿里云返回的Transcription完整JSON', null=True, verbose_name='转写原始数据'), + ), + ] diff --git a/backend/ai_services/migrations/0004_remove_transcriptiontask_evaluation_and_more.py b/backend/ai_services/migrations/0004_remove_transcriptiontask_evaluation_and_more.py new file mode 100644 index 0000000..9fd26b7 --- /dev/null +++ b/backend/ai_services/migrations/0004_remove_transcriptiontask_evaluation_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 6.0.1 on 2026-03-11 12:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai_services', '0003_transcriptiontask_auto_chapters_data_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='transcriptiontask', + name='evaluation', + ), + migrations.RemoveField( + model_name='transcriptiontask', + name='score', + ), + migrations.CreateModel( + name='AIEvaluation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score', models.IntegerField(blank=True, help_text='0-100分', null=True, verbose_name='AI评分')), + ('evaluation', models.TextField(blank=True, null=True, verbose_name='AI评语')), + ('model_selection', models.CharField(default='qwen-plus', help_text='例如: qwen-plus, qwen-turbo, qwen-max', max_length=50, verbose_name='模型选择')), + ('prompt', models.TextField(default='你是一个专业的评分助手。请根据提供的转写内容,对内容质量、逻辑清晰度、语言表达等方面进行综合评分(0-100分),并给出详细的评语。请以JSON格式返回,包含"score"和"evaluation"字段。', help_text='用于指导AI评分的提示词', verbose_name='评分提示词')), + ('raw_response', models.JSONField(blank=True, help_text='大模型返回的完整JSON', null=True, verbose_name='原始响应')), + ('reasoning', models.TextField(blank=True, help_text='AI的推理过程(如果有)', null=True, verbose_name='推理过程')), + ('status', models.CharField(choices=[('PENDING', '等待中'), ('PROCESSING', '生成中'), ('COMPLETED', '已完成'), ('FAILED', '失败')], default='PENDING', max_length=20, verbose_name='评估状态')), + ('error_message', models.TextField(blank=True, null=True, verbose_name='错误信息')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='ai_evaluation', to='ai_services.transcriptiontask', verbose_name='关联任务')), + ], + options={ + 'verbose_name': 'AI智能评估', + 'verbose_name_plural': 'AI智能评估', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/ai_services/migrations/0005_aievaluationtemplate_alter_aievaluation_options_and_more.py b/backend/ai_services/migrations/0005_aievaluationtemplate_alter_aievaluation_options_and_more.py new file mode 100644 index 0000000..ece8522 --- /dev/null +++ b/backend/ai_services/migrations/0005_aievaluationtemplate_alter_aievaluation_options_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 6.0.1 on 2026-03-11 13:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai_services', '0004_remove_transcriptiontask_evaluation_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='AIEvaluationTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='例如:销售话术评分、逻辑性分析', max_length=100, verbose_name='模板名称')), + ('model_selection', models.CharField(default='qwen-plus', help_text='例如: qwen-plus, qwen-turbo, qwen-max', max_length=50, verbose_name='模型选择')), + ('prompt', models.TextField(default='你是一个专业的评分助手。请根据提供的转写内容,对内容质量、逻辑清晰度、语言表达等方面进行综合评分(0-100分),并给出详细的评语。请以JSON格式返回,包含"score"和"evaluation"字段。', help_text='用于指导AI评分的提示词', verbose_name='评分提示词')), + ('is_active', models.BooleanField(default=True, help_text='启用后,新的转写任务完成后将自动使用此模板进行评估', verbose_name='是否启用')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': 'AI评估模板', + 'verbose_name_plural': 'AI评估模板', + 'ordering': ['-created_at'], + }, + ), + migrations.AlterModelOptions( + name='aievaluation', + options={'ordering': ['-created_at'], 'verbose_name': 'AI评估结果', 'verbose_name_plural': 'AI评估结果'}, + ), + migrations.AlterField( + model_name='aievaluation', + name='model_selection', + field=models.CharField(default='qwen-plus', max_length=50, verbose_name='模型选择'), + ), + migrations.AlterField( + model_name='aievaluation', + name='prompt', + field=models.TextField(verbose_name='评分提示词'), + ), + migrations.AlterField( + model_name='aievaluation', + name='task', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_evaluations', to='ai_services.transcriptiontask', verbose_name='关联任务'), + ), + migrations.AddField( + model_name='aievaluation', + name='template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='evaluations', to='ai_services.aievaluationtemplate', verbose_name='使用的模板'), + ), + ] diff --git a/backend/ai_services/migrations/0006_transcriptiontask_project.py b/backend/ai_services/migrations/0006_transcriptiontask_project.py new file mode 100644 index 0000000..3f34830 --- /dev/null +++ b/backend/ai_services/migrations/0006_transcriptiontask_project.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.1 on 2026-03-11 14:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai_services', '0005_aievaluationtemplate_alter_aievaluation_options_and_more'), + ('competition', '0003_competition_project_visibility'), + ] + + operations = [ + migrations.AddField( + model_name='transcriptiontask', + name='project', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transcription_tasks', to='competition.project', verbose_name='关联参赛项目'), + ), + ] diff --git a/backend/ai_services/migrations/0007_aievaluationtemplate_score_dimension.py b/backend/ai_services/migrations/0007_aievaluationtemplate_score_dimension.py new file mode 100644 index 0000000..642319e --- /dev/null +++ b/backend/ai_services/migrations/0007_aievaluationtemplate_score_dimension.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.1 on 2026-03-11 15:03 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai_services', '0006_transcriptiontask_project'), + ('competition', '0003_competition_project_visibility'), + ] + + operations = [ + migrations.AddField( + model_name='aievaluationtemplate', + name='score_dimension', + field=models.ForeignKey(blank=True, help_text='如果同步到比赛评分,优先使用此维度。未填写则默认使用"AI Rating"或包含"AI"的维度', null=True, on_delete=django.db.models.deletion.SET_NULL, to='competition.scoredimension', verbose_name='关联评分维度'), + ), + ] diff --git a/backend/ai_services/migrations/0008_add_is_default_to_template.py b/backend/ai_services/migrations/0008_add_is_default_to_template.py new file mode 100644 index 0000000..9c0a799 --- /dev/null +++ b/backend/ai_services/migrations/0008_add_is_default_to_template.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-03-17 15:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai_services', '0007_aievaluationtemplate_score_dimension'), + ] + + operations = [ + migrations.AddField( + model_name='aievaluationtemplate', + name='is_default', + field=models.BooleanField(default=False, help_text='默认模板会评价所有比赛,非默认模板且未关联评分维度时不会自动评价', verbose_name='是否为默认模板'), + ), + ] diff --git a/backend/ai_services/migrations/0009_alter_aievaluation_id_alter_aievaluationtemplate_id.py b/backend/ai_services/migrations/0009_alter_aievaluation_id_alter_aievaluationtemplate_id.py new file mode 100644 index 0000000..078d7af --- /dev/null +++ b/backend/ai_services/migrations/0009_alter_aievaluation_id_alter_aievaluationtemplate_id.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.29 on 2026-03-18 12:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ai_services', '0008_add_is_default_to_template'), + ] + + operations = [ + migrations.AlterField( + model_name='aievaluation', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='aievaluationtemplate', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/backend/ai_services/migrations/__init__.py b/backend/ai_services/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/ai_services/models.py b/backend/ai_services/models.py new file mode 100644 index 0000000..2e174f4 --- /dev/null +++ b/backend/ai_services/models.py @@ -0,0 +1,150 @@ +import uuid +from django.db import models +from django.utils.translation import gettext_lazy as _ + +class TranscriptionTask(models.Model): + class Status(models.TextChoices): + PENDING = 'PENDING', _('等待中') + PROCESSING = 'PROCESSING', _('处理中') + SUCCEEDED = 'SUCCEEDED', _('成功') + FAILED = 'FAILED', _('失败') + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + file_url = models.URLField(verbose_name=_('文件链接'), max_length=1024) + task_id = models.CharField(verbose_name=_('听悟任务ID'), max_length=100, blank=True, null=True) + status = models.CharField( + verbose_name=_('状态'), + max_length=20, + choices=Status.choices, + default=Status.PENDING + ) + # 存储阿里云听悟返回的原始 JSON 结构 + transcription_data = models.JSONField(verbose_name=_('转写原始数据'), blank=True, null=True, help_text=_('阿里云返回的Transcription完整JSON')) + summary_data = models.JSONField(verbose_name=_('总结原始数据'), blank=True, null=True, help_text=_('阿里云返回的Summarization完整JSON')) + auto_chapters_data = models.JSONField(verbose_name=_('章节原始数据'), blank=True, null=True, help_text=_('阿里云返回的AutoChapters完整JSON')) + + project = models.ForeignKey( + 'competition.Project', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='transcription_tasks', + verbose_name=_('关联参赛项目') + ) + + transcription = models.TextField(verbose_name=_('逐字稿'), blank=True, null=True) + summary = models.TextField(verbose_name=_('AI总结'), blank=True, null=True) + + # 已解耦到 AIEvaluation 模型 + # score = models.IntegerField(verbose_name=_('AI评分'), blank=True, null=True, help_text=_('基于转写内容的评分')) + # evaluation = models.TextField(verbose_name=_('AI评语'), blank=True, null=True) + + error_message = models.TextField(verbose_name=_('错误信息'), blank=True, null=True) + created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True) + updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True) + + class Meta: + verbose_name = _('转写任务') + verbose_name_plural = _('转写任务') + ordering = ['-created_at'] + + def __str__(self): + return f"{self.id} - {self.get_status_display()}" + + +class AIEvaluationTemplate(models.Model): + name = models.CharField(verbose_name=_('模板名称'), max_length=100, help_text=_('例如:销售话术评分、逻辑性分析')) + model_selection = models.CharField( + verbose_name=_('模型选择'), + max_length=50, + default='qwen-plus', + help_text=_('例如: qwen-plus, qwen-turbo, qwen-max') + ) + prompt = models.TextField( + verbose_name=_('评分提示词'), + default='你是一个专业的评分助手。请根据提供的转写内容,对内容质量、逻辑清晰度、语言表达等方面进行综合评分(0-100分),并给出详细的评语。请以JSON格式返回,包含"score"和"evaluation"字段。', + help_text=_('用于指导AI评分的提示词') + ) + score_dimension = models.ForeignKey( + 'competition.ScoreDimension', + on_delete=models.SET_NULL, + null=True, + blank=True, + verbose_name=_('关联评分维度'), + help_text=_('如果同步到比赛评分,优先使用此维度。未填写则默认使用"AI Rating"或包含"AI"的维度') + ) + is_default = models.BooleanField( + verbose_name=_('是否为默认模板'), + default=False, + help_text=_('默认模板会评价所有比赛,非默认模板且未关联评分维度时不会自动评价') + ) + is_active = models.BooleanField(verbose_name=_('是否启用'), default=True, help_text=_('启用后,新的转写任务完成后将自动使用此模板进行评估')) + + created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True) + updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True) + + class Meta: + verbose_name = _('AI评估模板') + verbose_name_plural = _('AI评估模板') + ordering = ['-created_at'] + + def __str__(self): + return self.name + + +class AIEvaluation(models.Model): + class Status(models.TextChoices): + PENDING = 'PENDING', _('等待中') + PROCESSING = 'PROCESSING', _('生成中') + COMPLETED = 'COMPLETED', _('已完成') + FAILED = 'FAILED', _('失败') + + task = models.ForeignKey( + TranscriptionTask, + on_delete=models.CASCADE, + related_name='ai_evaluations', + verbose_name=_('关联任务') + ) + template = models.ForeignKey( + AIEvaluationTemplate, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='evaluations', + verbose_name=_('使用的模板') + ) + + # 评分与评语 + score = models.IntegerField(verbose_name=_('AI评分'), blank=True, null=True, help_text=_('0-100分')) + evaluation = models.TextField(verbose_name=_('AI评语'), blank=True, null=True) + + # 记录当时的配置 (快照) + model_selection = models.CharField( + verbose_name=_('模型选择'), + max_length=50, + default='qwen-plus' + ) + prompt = models.TextField(verbose_name=_('评分提示词')) + + # 原始数据与推理 + raw_response = models.JSONField(verbose_name=_('原始响应'), blank=True, null=True, help_text=_('大模型返回的完整JSON')) + reasoning = models.TextField(verbose_name=_('推理过程'), blank=True, null=True, help_text=_('AI的推理过程(如果有)')) + + status = models.CharField( + verbose_name=_('评估状态'), + max_length=20, + choices=Status.choices, + default=Status.PENDING + ) + error_message = models.TextField(verbose_name=_('错误信息'), blank=True, null=True) + + created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True) + updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True) + + class Meta: + verbose_name = _('AI评估结果') + verbose_name_plural = _('AI评估结果') + ordering = ['-created_at'] + + def __str__(self): + return f"Evaluation for Task {self.task.id} ({self.template.name if self.template else 'Custom'})" diff --git a/backend/ai_services/serializers.py b/backend/ai_services/serializers.py new file mode 100644 index 0000000..ee074fb --- /dev/null +++ b/backend/ai_services/serializers.py @@ -0,0 +1,28 @@ +from rest_framework import serializers +from .models import TranscriptionTask, AIEvaluation, AIEvaluationTemplate + +class AIEvaluationTemplateSerializer(serializers.ModelSerializer): + class Meta: + model = AIEvaluationTemplate + fields = ['id', 'name', 'model_selection', 'prompt', 'is_active', 'created_at'] + +class AIEvaluationSerializer(serializers.ModelSerializer): + template = AIEvaluationTemplateSerializer(read_only=True) + + class Meta: + model = AIEvaluation + fields = ['id', 'template', 'score', 'evaluation', 'model_selection', 'prompt', 'reasoning', 'status', 'error_message', 'created_at', 'updated_at'] + +class TranscriptionTaskSerializer(serializers.ModelSerializer): + ai_evaluations = AIEvaluationSerializer(many=True, read_only=True) + project_title = serializers.CharField(source='project.title', read_only=True) + + class Meta: + model = TranscriptionTask + fields = ['id', 'file_url', 'task_id', 'status', 'transcription', 'summary', 'error_message', 'created_at', 'updated_at', 'transcription_data', 'summary_data', 'auto_chapters_data', 'ai_evaluations', 'project', 'project_title'] + read_only_fields = ['id', 'file_url', 'task_id', 'status', 'transcription', 'summary', 'error_message', 'created_at', 'updated_at', 'transcription_data', 'summary_data', 'auto_chapters_data', 'ai_evaluations', 'project_title'] + +class TranscriptionUploadSerializer(serializers.Serializer): + file = serializers.FileField(help_text="上传的音频文件", required=False) + file_url = serializers.URLField(help_text="音频文件的URL地址", required=False) + project_id = serializers.IntegerField(help_text="关联的参赛项目ID", required=False) diff --git a/backend/ai_services/services.py b/backend/ai_services/services.py new file mode 100644 index 0000000..cd64249 --- /dev/null +++ b/backend/ai_services/services.py @@ -0,0 +1,420 @@ +import json +import logging +import time +import uuid +import oss2 +from aliyunsdkcore.client import AcsClient +from aliyunsdkcore.acs_exception.exceptions import ClientException, ServerException +# 尝试导入最新的 API 版本,如果有问题可能需要调整 +try: + from aliyunsdktingwu.request.v20230930 import CreateTaskRequest, GetTaskInfoRequest +except ImportError: + # Fallback or error handling if version differs + pass + +from django.conf import settings + +logger = logging.getLogger(__name__) + +from .models import TranscriptionTask, AIEvaluation, AIEvaluationTemplate + +class AliyunTingwuService: + def __init__(self): + self.access_key_id = settings.ALIYUN_ACCESS_KEY_ID + self.access_key_secret = settings.ALIYUN_ACCESS_KEY_SECRET + self.oss_bucket_name = settings.ALIYUN_OSS_BUCKET_NAME + self.oss_endpoint = settings.ALIYUN_OSS_ENDPOINT + self.tingwu_app_key = settings.ALIYUN_TINGWU_APP_KEY + self.region_id = "cn-shanghai" # 听悟服务区域,根据文档应与OSS区域一致,或者使用 'cn-beijing' + + # 初始化 OSS Bucket + if self.access_key_id and self.access_key_secret and self.oss_endpoint: + auth = oss2.Auth(self.access_key_id, self.access_key_secret) + self.bucket = oss2.Bucket(auth, self.oss_endpoint, self.oss_bucket_name) + else: + self.bucket = None + logger.warning("Aliyun OSS configuration missing.") + + # 初始化听悟 Client + if self.access_key_id and self.access_key_secret: + self.client = AcsClient( + self.access_key_id, + self.access_key_secret, + self.region_id + ) + # 显式添加听悟服务的 Endpoint 映射,解决 EndpointResolvingError + # 听悟 API 的服务接入点通常是 tingwu.cn-beijing.aliyuncs.com + # 但新版听悟 API (tingwu.aliyuncs.com) 可能不同,需根据实际情况添加 + # 这里添加一个通用的 Endpoint 映射 + try: + # 尝试为 tingwu 产品设置 Endpoint + # 注意:听悟服务主要部署在北京,Endpoint 通常为 tingwu.cn-beijing.aliyuncs.com + # 如果您的服务在上海,也可能需要连接到北京的接入点 + self.client.add_endpoint(self.region_id, "tingwu", "tingwu.cn-beijing.aliyuncs.com") + except Exception as e: + logger.warning(f"Failed to add endpoint: {e}") + + else: + self.client = None + logger.warning("Aliyun AccessKey configuration missing.") + + def upload_to_oss(self, file_obj, file_name, day=7): + """ + 上传文件到 OSS 并返回带签名的 URL + 默认生成有效期为 7 天 (3600 * 24 * day) 的签名URL,方便评委在一段时间内都能播放。 + """ + if not self.bucket: + raise Exception("OSS Client not initialized") + + try: + # 上传文件 + # file_obj 应该是打开的文件对象或字节流 + self.bucket.put_object(file_name, file_obj) + + # 生成签名 URL,有效期 7 天 (3600 * 24 * 7 = 604800 秒) + url = self.bucket.sign_url('GET', file_name, 3600 * 24 * day) + return url + except Exception as e: + logger.error(f"OSS Upload failed: {e}") + raise e + + def create_transcription_task(self, file_url, language="cn"): + """ + 创建听悟转写任务 + """ + if not self.client: + raise Exception("Tingwu Client not initialized") + + request = CreateTaskRequest.CreateTaskRequest() + + # 针对阿里云 SDK 不同版本的兼容性处理 + # "type" 参数是听悟 API (ROA 风格) 的必填项,用于指定任务类型 + # 根据官方文档,离线任务的 type 通常就是 'offline' + request.add_query_param('type', 'offline') + + # 构造请求体 (Body) + # 根据听悟 API 文档,AppKey, Input, Parameters 应位于 JSON Body 中 + # 而不是 Query Parameter + body = { + "AppKey": self.tingwu_app_key, + "Input": { + "FileUrl": file_url, + "SourceLanguage": language, + "TaskKey": str(uuid.uuid4()) + }, + "Parameters": { + "Transcoding": { + "TargetAudioFormat": "mp3" + }, + "Transcription": { + "DiarizationEnabled": True, + "ChannelId": 0 + }, + "TranscriptionEnabled": True, + "AutoChaptersEnabled": True, + "SummarizationEnabled": True, + "Summarization": { + "Types": ["Paragraph", "Conversational", "QuestionsAnswering", "MindMap"] + } + } + } + + # 设置 Body 内容 + request.set_content(json.dumps(body)) + request.add_header('Content-Type', 'application/json') + + # 强制设置 Endpoint,避免 SDK.EndpointResolvingError + # 听悟目前主要服务点在北京 + request.set_endpoint("tingwu.cn-beijing.aliyuncs.com") + + # 显式设置 Method 为 PUT + request.set_method('PUT') + + try: + response = self.client.do_action_with_exception(request) + return json.loads(response) + except (ClientException, ServerException) as e: + logger.error(f"Tingwu CreateTask failed: {e}") + raise e + + def get_task_info(self, task_id): + """ + 查询任务状态和结果 + """ + if not self.client: + raise Exception("Tingwu Client not initialized") + + request = GetTaskInfoRequest.GetTaskInfoRequest() + request.set_TaskId(task_id) + + try: + response = self.client.do_action_with_exception(request) + return json.loads(response) + except (ClientException, ServerException) as e: + logger.error(f"Tingwu GetTaskInfo failed: {e}") + raise e + + def parse_and_update_task(self, task, result): + """ + 解析听悟结果并更新任务 + :param task: TranscriptionTask 实例 + :param result: get_task_info 返回的完整 JSON (或 Data 部分) + """ + # 记录之前的状态,用于判断是否是首次完成 + previous_status = task.status + + # 1. 提取 Data 对象 + if isinstance(result, dict): + data_obj = result.get('Data', result) + else: + data_obj = result + + if not isinstance(data_obj, dict): + logger.error(f"Unexpected data format: {type(data_obj)}") + return + + # 2. 更新状态 + task_status = data_obj.get('TaskStatus') or data_obj.get('Status') + if task_status in ['COMPLETE', 'COMPLETED', 'SUCCEEDED']: + task.status = 'SUCCEEDED' # 使用字符串引用,避免导入模型循环引用 + elif task_status == 'FAILED': + task.status = 'FAILED' + task.error_message = data_obj.get('TaskStatusText', data_obj.get('Message', 'Unknown error')) + task.save() + return + else: + # 仍在处理中,不更新内容 + return + + # 3. 解析结果 + task_result = data_obj.get('Result', {}) + + # 兼容处理:如果 Result 为空,或者不存在,尝试直接使用 data_obj 作为结果源 + # 某些情况下,Summarization/AutoChapters 可能直接位于 Data 层级 + if not task_result: + task_result = data_obj + + # 辅助函数:从源字典或其 Result 子字典中获取字段 + def get_data_field(source, key): + # 1. 尝试直接从 task_result 获取 (如果 task_result 就是 Data 本身,这里也会生效) + if isinstance(source, dict) and key in source: + return source[key] + # 2. 如果 source 是 Data,尝试从 source['Result'] 获取 + if isinstance(source, dict) and 'Result' in source and isinstance(source['Result'], dict): + if key in source['Result']: + return source['Result'][key] + return None + + # --- A. 处理逐字稿 (Transcription) --- + transcription_data = get_data_field(task_result, 'Transcription') or get_data_field(data_obj, 'Transcription') or {} + + # 处理 URL 下载 + if isinstance(transcription_data, str) and transcription_data.startswith('http'): + try: + import requests + t_resp = requests.get(transcription_data) + if t_resp.status_code == 200: + transcription_data = t_resp.json() + except Exception as e: + logger.error(f"Download transcription failed: {e}") + transcription_data = {} + elif isinstance(transcription_data, dict) and 'TranscriptionUrl' in transcription_data: + try: + import requests + t_resp = requests.get(transcription_data['TranscriptionUrl']) + if t_resp.status_code == 200: + transcription_data = t_resp.json() + except Exception as e: + logger.error(f"Download transcription url failed: {e}") + + # 保存原始数据 + task.transcription_data = transcription_data + + # 提取文本 + # 结构: {"Transcription": {"Paragraphs": [{"Words": [{"Text": "..."}]}]}} + # 或直接 {"Paragraphs": ...} + content_source = transcription_data + if 'Transcription' in content_source and isinstance(content_source['Transcription'], dict): + content_source = content_source['Transcription'] + + paragraphs = content_source.get('Paragraphs', []) + full_text_lines = [] + + if paragraphs and isinstance(paragraphs, list): + for p in paragraphs: + # 尝试从 Words 中提取 + words = p.get('Words', []) + if words: + line_text = "".join([str(w.get('Text', '')) for w in words]) + full_text_lines.append(line_text) + # 兼容旧结构或直接 Text + elif 'Text' in p: + full_text_lines.append(p['Text']) + + if full_text_lines: + task.transcription = "\n".join(full_text_lines) + + # --- B. 处理 AI 总结 (Summarization) --- + summarization = get_data_field(task_result, 'Summarization') or get_data_field(data_obj, 'Summarization') or {} + + # 处理 URL 下载 + if isinstance(summarization, str) and summarization.startswith('http'): + try: + import requests + s_resp = requests.get(summarization) + if s_resp.status_code == 200: + summarization = s_resp.json() + except Exception as e: + logger.error(f"Download summarization failed: {e}") + summarization = {} + + # 保存原始数据 + task.summary_data = summarization + + # 提取文本 (MindMapSummary) + # 结构: {"MindMapSummary": [{"Title": "...", "Topic": [...]}]} + # 移除了原先的 summary_text 拼接逻辑 + + # --- C. 处理章节 (AutoChapters) --- + auto_chapters = get_data_field(task_result, 'AutoChapters') or get_data_field(data_obj, 'AutoChapters') or [] + + # 处理 URL 下载 + if isinstance(auto_chapters, str) and auto_chapters.startswith('http'): + try: + import requests + ac_resp = requests.get(auto_chapters) + if ac_resp.status_code == 200: + auto_chapters = ac_resp.json() + except Exception as e: + logger.error(f"Download auto chapters failed: {e}") + auto_chapters = [] + + # 保存原始数据 + task.auto_chapters_data = auto_chapters + + # 保存任务,确保原始数据已写入数据库 + task.save() + + # 调用大模型生成总结 (如果 summary_data 或 auto_chapters_data 存在) + if task.summary_data or task.auto_chapters_data: + try: + # 设置占位状态 + task.summary = "AI总结生成当中..." + task.save(update_fields=['summary']) + + # 异步执行总结 + import threading + from .bailian_service import BailianService + + def async_summarize_in_service(task_id): + try: + # 重新获取 task 以避免线程安全问题 + from .models import TranscriptionTask + t = TranscriptionTask.objects.get(id=task_id) + bailian_service = BailianService() + bailian_service.summarize_task(t) + except Exception as e: + logger.error(f"Async summary generation failed in service: {e}") + + threading.Thread(target=async_summarize_in_service, args=(task.id,)).start() + logger.info(f"Triggered async summary generation for task {task.id}") + + except Exception as e: + logger.error(f"Failed to trigger AI summarization: {e}") + + # 4. 自动触发 AI 评估 (如果任务首次成功且有启用的模板) + if previous_status != 'SUCCEEDED' and task.status == 'SUCCEEDED' and task.transcription: + # 同样改为异步触发,传递 task.id 以避免线程中的对象状态问题 + import threading + threading.Thread(target=self.trigger_ai_evaluations, args=(task.id,)).start() + + def trigger_ai_evaluations(self, task_id): + """ + 根据启用的模板自动触发 AI 评估 + + 逻辑: + 1. 如果模板关联了评分维度(s score_dimension),只对关联了相同维度的比赛进行评估 + 2. 如果模板未关联评分维度: + - 如果是默认模板(is_default=True),评价所有比赛 + - 否则不进行自动评价 + """ + try: + # 在线程中重新获取 task 对象,并预加载 project,避免懒加载导致的线程数据库连接问题 + from .models import TranscriptionTask + task = TranscriptionTask.objects.select_related('project', 'project__competition').get(id=task_id) + except Exception as e: + # 兼容处理:如果 task_id 其实是 task 对象(虽然我们上面改了,但防止其他地方调用传错) + if hasattr(task_id, 'id'): + try: + from .models import TranscriptionTask + task = TranscriptionTask.objects.select_related('project', 'project__competition').get(id=task_id.id) + except: + task = task_id + else: + logger.error(f"Failed to retrieve task {task_id}: {e}") + return + + active_templates = AIEvaluationTemplate.objects.filter(is_active=True) + if not active_templates.exists(): + logger.info("No active AI evaluation templates found.") + return + + from .bailian_service import BailianService + service = BailianService() + + for template in active_templates: + # 检查是否已经存在相同的评估,避免重复创建 + if AIEvaluation.objects.filter(task=task, template=template).exists(): + logger.info(f"Evaluation for task {task.id} and template {template.name} already exists.") + continue + + # 获取任务关联的比赛 + task_competition = None + if task.project and task.project.competition: + task_competition = task.project.competition + + # 判断是否应该对此任务进行评估 + should_evaluate = False + + if template.score_dimension: + # 模板关联了评分维度,只对关联了相同维度的比赛进行评估 + if task_competition: + # 获取该比赛下所有关联了相同评分维度的比赛ID列表 + from competition.models import ScoreDimension + related_competition_ids = ScoreDimension.objects.filter( + id=template.score_dimension.id + ).values_list('competition_id', flat=True) + + if task_competition.id in related_competition_ids: + should_evaluate = True + logger.info(f"Template '{template.name}' is linked to score_dimension, task's competition matches.") + else: + logger.info(f"Template '{template.name}' is linked to score_dimension, but task's competition does not match. Skipping.") + else: + logger.info(f"Task {task.id} has no associated competition. Skipping template '{template.name}'.") + else: + # 模板未关联评分维度,只有默认模板才评价所有比赛 + if template.is_default: + should_evaluate = True + logger.info(f"Template '{template.name}' is default template, evaluating all competitions.") + else: + logger.info(f"Template '{template.name}' is not linked to score_dimension and is not default. Skipping.") + + if not should_evaluate: + continue + + # 创建评估记录 + evaluation = AIEvaluation.objects.create( + task=task, + template=template, + model_selection=template.model_selection, + prompt=template.prompt, + status=AIEvaluation.Status.PENDING + ) + + # 触发评估 + try: + service.evaluate_task(evaluation) + logger.info(f"Triggered evaluation {evaluation.id} for template {template.name}") + except Exception as e: + logger.error(f"Failed to trigger evaluation {evaluation.id}: {e}") diff --git a/backend/ai_services/tests.py b/backend/ai_services/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/ai_services/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/ai_services/urls.py b/backend/ai_services/urls.py new file mode 100644 index 0000000..ee9a672 --- /dev/null +++ b/backend/ai_services/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import TranscriptionTaskViewSet, tingwu_callback + +router = DefaultRouter() +router.register(r'transcriptions', TranscriptionTaskViewSet) + +urlpatterns = [ + path('callback/', tingwu_callback, name='tingwu-callback'), + path('', include(router.urls)), +] diff --git a/backend/ai_services/views.py b/backend/ai_services/views.py new file mode 100644 index 0000000..aea9c6f --- /dev/null +++ b/backend/ai_services/views.py @@ -0,0 +1,364 @@ +import logging +import uuid +from rest_framework import viewsets, status +from rest_framework.decorators import action, api_view, permission_classes, parser_classes +from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework.permissions import AllowAny +from django.conf import settings +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes +from .models import TranscriptionTask, AIEvaluation +from .serializers import TranscriptionTaskSerializer, TranscriptionUploadSerializer, AIEvaluationSerializer +from .services import AliyunTingwuService + +logger = logging.getLogger(__name__) + +@api_view(['POST']) +@permission_classes([AllowAny]) +def tingwu_callback(request): + """ + 处理阿里云听悟的回调消息 + """ + data = request.data + logger.info(f"收到听悟回调: {data}") + + # 1. 处理连通性测试消息 + # 格式: {"Code": "0", "Data": {"Test": "..."}, "Message": "success", "RequestId": "..."} + if isinstance(data, dict) and 'Data' in data and 'Test' in data['Data']: + logger.info("收到听悟连通性测试请求") + return Response({'message': 'success'}, status=status.HTTP_200_OK) + + # 2. 处理任务完成消息 (根据实际文档或后续调试完善) + # 通常会包含 TaskId 和 Status + # 注意:阿里云听悟回调的结构可能在 Header 或 Body 中不同,需根据实际情况调整 + # 这里是一个通用的处理逻辑 + task_id = data.get('TaskId') + task_status = data.get('Status') + + if task_id: + try: + task = TranscriptionTask.objects.filter(task_id=task_id).first() + if task: + if task_status == 'COMPLETE': + logger.info(f"任务 {task_id} 完成,等待下一次查询刷新") + # 可以在这里直接调用 get_task_info 刷新数据,但要注意超时 + elif task_status == 'FAILED': + task.status = TranscriptionTask.Status.FAILED + task.error_message = data.get('StatusText', 'Callback reported failure') + task.save() + else: + logger.warning(f"回调收到未知任务ID: {task_id}") + except Exception as e: + logger.error(f"处理回调异常: {e}") + + return Response({'message': 'success'}, status=status.HTTP_200_OK) + +class TranscriptionTaskViewSet(viewsets.ModelViewSet): + queryset = TranscriptionTask.objects.all() + serializer_class = TranscriptionTaskSerializer + parser_classes = (MultiPartParser, FormParser) + + @extend_schema( + request={ + 'multipart/form-data': { + 'type': 'object', + 'properties': { + 'file': { + 'type': 'string', + 'format': 'binary' + }, + 'file_url': { + 'type': 'string', + 'description': '音频文件的URL地址' + }, + 'project_id': { + 'type': 'integer', + 'description': '关联的参赛项目ID' + } + } + } + }, + responses={201: TranscriptionTaskSerializer} + ) + def create(self, request, *args, **kwargs): + """ + 上传音频文件并创建听悟转写任务 + """ + file_obj = request.FILES.get('file') + file_url = request.data.get('file_url') + project_id = request.data.get('project_id') + + if not file_obj and not file_url: + return Response({'error': '请提供文件或文件URL'}, status=status.HTTP_400_BAD_REQUEST) + + service = AliyunTingwuService() + if not service.bucket or not service.client: + return Response({'error': '阿里云服务未配置'}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + + try: + oss_url = None + if file_obj: + # 1. 上传文件到 OSS + file_extension = file_obj.name.split('.')[-1] + file_name = f"transcription/{uuid.uuid4()}.{file_extension}" + + # 使用服务上传 + oss_url = service.upload_to_oss(file_obj, file_name) + else: + # 使用提供的 URL + oss_url = file_url + + # 2. 创建数据库记录 + task_data = { + 'file_url': oss_url, + 'status': TranscriptionTask.Status.PENDING + } + if project_id: + try: + p_id = int(project_id) + # 只有当 ID > 0 时才认为是有效的项目 ID + # 避免前端传递 0 或 Swagger 默认值导致的外键约束错误 + if p_id > 0: + task_data['project_id'] = p_id + except (ValueError, TypeError): + pass # Ignore invalid project_id + + task_record = TranscriptionTask.objects.create(**task_data) + logger.info(f"Created TranscriptionTask {task_record.id} with project_id={project_id}") + + # 3. 调用听悟接口创建任务 + try: + tingwu_response = service.create_transcription_task(oss_url) + + # 兼容处理响应结构,通常为 {"Data": {"TaskId": "...", ...}} + if 'Data' in tingwu_response and isinstance(tingwu_response['Data'], dict): + task_id = tingwu_response['Data'].get('TaskId') + else: + task_id = tingwu_response.get('TaskId') + + if task_id: + task_record.task_id = task_id + task_record.status = TranscriptionTask.Status.PROCESSING + task_record.save() + else: + task_record.status = TranscriptionTask.Status.FAILED + task_record.error_message = "未能获取 TaskId" + task_record.save() + return Response({'error': '未能获取 TaskId'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + except Exception as e: + task_record.status = TranscriptionTask.Status.FAILED + task_record.error_message = str(e) + task_record.save() + logger.error(f"创建听悟任务失败: {e}") + return Response({'error': f"创建听悟任务失败: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + serializer = self.get_serializer(task_record) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + except Exception as e: + logger.error(f"处理上传请求失败: {e}") + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=True, methods=['post']) + @extend_schema( + request={ + 'application/json': { + 'type': 'object', + 'properties': { + 'model_selection': {'type': 'string', 'description': '模型选择'}, + 'prompt': {'type': 'string', 'description': '评分提示词'}, + } + } + }, + responses={200: AIEvaluationSerializer(many=True)} + ) + def evaluate(self, request, pk=None): + """ + 触发AI评估 + """ + task = self.get_object() + + # 1. 如果有 active template,触发所有 active template + # 2. 如果请求体提供了 custom prompt,则创建一个 custom evaluation (no template) + + from .models import AIEvaluationTemplate + from .bailian_service import BailianService + service = BailianService() + + evaluations_to_process = [] + + # A. 如果指定了 Prompt/Model,视为手动单次评估 + model_selection = request.data.get('model_selection') + prompt = request.data.get('prompt') + + if prompt: + # 创建一个不关联 Template 的评估 + eval, _ = AIEvaluation.objects.get_or_create( + task=task, + template=None, + defaults={ + 'model_selection': model_selection or 'qwen-plus', + 'prompt': prompt + } + ) + # 更新配置 + eval.model_selection = model_selection or eval.model_selection + eval.prompt = prompt + eval.save() + evaluations_to_process.append(eval) + else: + # B. 否则触发所有 Active Templates + active_templates = AIEvaluationTemplate.objects.filter(is_active=True) + if not active_templates.exists(): + return Response({'message': 'No active templates and no custom prompt provided'}, status=status.HTTP_400_BAD_REQUEST) + + for t in active_templates: + eval, _ = AIEvaluation.objects.get_or_create( + task=task, + template=t, + defaults={ + 'model_selection': t.model_selection, + 'prompt': t.prompt + } + ) + # 始终更新为模板最新配置? 或者保留历史? 用户意图似乎是"模版搭好...启用...生成几份" + # 这里假设触发时应用模板当前配置 + eval.model_selection = t.model_selection + eval.prompt = t.prompt + eval.save() + evaluations_to_process.append(eval) + + # 执行评估 (改为异步并发执行) + # 提取ID列表,避免传递模型对象导致可能的线程问题 + eval_ids = [e.id for e in evaluations_to_process] + + if eval_ids: + import threading + from concurrent.futures import ThreadPoolExecutor + + def run_evaluations_background(ids): + # 在后台线程中重新引入依赖 + from .models import AIEvaluation + from .bailian_service import BailianService + + # 为该线程创建独立的服务实例 + local_service = BailianService() + + # 获取最新的对象 + target_evals = AIEvaluation.objects.filter(id__in=ids) + + # 使用线程池并发执行 + # max_workers=4 可以同时处理4个评估请求 + with ThreadPoolExecutor(max_workers=4) as executor: + executor.map(local_service.evaluate_task, target_evals) + + # 启动后台线程,不阻塞当前 HTTP 请求 + thread = threading.Thread(target=run_evaluations_background, args=(eval_ids,)) + thread.daemon = True # 设置为守护线程 + thread.start() + + # 返回该任务的所有评估结果 + all_evals = AIEvaluation.objects.filter(task=task) + serializer = AIEvaluationSerializer(all_evals, many=True) + return Response(serializer.data) + + @action(detail=True, methods=['get']) + @extend_schema( + parameters=[ + OpenApiParameter("id", OpenApiTypes.UUID, OpenApiParameter.PATH, description="Task ID"), + ], + responses={200: TranscriptionTaskSerializer} + ) + def refresh_status(self, request, pk=None): + """ + 刷新任务状态并获取结果 + """ + task = self.get_object() + + # 允许刷新的条件: + # 1. 任务未完成 (PENDING, PROCESSING) + # 2. 任务已完成但逐字稿 (transcription) 为空 + # 3. 任务已完成但 AI总结 (summary) 为空 (新增) + + should_refresh = False + if task.status not in [TranscriptionTask.Status.SUCCEEDED, TranscriptionTask.Status.FAILED]: + should_refresh = True + elif task.status == TranscriptionTask.Status.SUCCEEDED: + if not task.transcription or not task.summary: + should_refresh = True + + if not should_refresh: + serializer = self.get_serializer(task) + return Response(serializer.data) + + if not task.task_id: + return Response({'error': '任务ID不存在'}, status=status.HTTP_400_BAD_REQUEST) + + service = AliyunTingwuService() + try: + result = service.get_task_info(task.task_id) + + # 兼容处理响应结构 {"Data": {"TaskStatus": "...", "Result": ...}} + # 有些情况下 SDK 返回的是 JSON 字符串,需要二次解析 + if isinstance(result, str): + import json + try: + result = json.loads(result) + except: + pass + + if isinstance(result, dict): + data_obj = result.get('Data', result) + else: + data_obj = result + if not isinstance(data_obj, dict): + # 如果 Data 不是字典,可能它本身就是字符串,或者 result 结构更平铺 + data_obj = result + + # 防御性编程:确保 data_obj 是字典 + if not isinstance(data_obj, dict): + logger.error(f"Unexpected response format: {type(data_obj)} - {data_obj}") + return Response({'error': f"Unexpected response format: {type(data_obj)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # 调用 Service 进行解析和更新 + service.parse_and_update_task(task, result) + + # 如果任务成功但 AI 总结仍为空 (可能之前解析没触发,或者大模型调用失败) + # 再次尝试强制触发 summarize_task (如果原始数据存在) + # 注意:service.parse_and_update_task 内部已经尝试异步触发,这里作为补救措施 + if task.status == TranscriptionTask.Status.SUCCEEDED and not task.summary: + if task.summary_data or task.auto_chapters_data: + try: + # 先设置状态为 "AI总结生成当中..." + task.summary = "AI总结生成当中..." + task.save(update_fields=['summary']) + + # 异步触发总结生成 + import threading + from .bailian_service import BailianService + + def async_summarize(task_id): + try: + # 重新获取 task 对象以避免线程问题 + from .models import TranscriptionTask + task_obj = TranscriptionTask.objects.get(id=task_id) + bailian_service = BailianService() + bailian_service.summarize_task(task_obj) + except Exception as e: + logger.error(f"Async summary generation failed: {e}") + + threading.Thread(target=async_summarize, args=(task.id,)).start() + + except Exception as e: + logger.error(f"Force trigger AI summarization failed: {e}") + + # 重新获取 task 以包含更新后的关联字段 + task.refresh_from_db() + + serializer = self.get_serializer(task) + return Response(serializer.data) + + except Exception as e: + logger.error(f"刷新任务状态失败: {e}") + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/backend/check_urls.py b/backend/check_urls.py new file mode 100644 index 0000000..19ca943 --- /dev/null +++ b/backend/check_urls.py @@ -0,0 +1,30 @@ +import os +import django +from django.urls import reverse +from django.conf import settings + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() + +links = [ + "admin:shop_wechatuser_changelist", + "admin:shop_salesperson_changelist", + "admin:shop_distributor_changelist", + "admin:shop_esp32config_changelist", + "admin:shop_service_changelist", + "admin:shop_VBcourse_changelist", + "admin:shop_order_changelist", + "admin:shop_serviceorder_changelist", + "admin:shop_withdrawal_changelist", + "admin:shop_commissionlog_changelist", + "admin:shop_wechatpayconfig_changelist", + "admin:auth_user_changelist", +] + +print("Checking URL patterns...") +for link in links: + try: + url = reverse(link) + print(f"[OK] {link} -> {url}") + except Exception as e: + print(f"[ERROR] {link}: {e}") diff --git a/backend/community/__init__.py b/backend/community/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/community/admin.py b/backend/community/admin.py new file mode 100644 index 0000000..4372485 --- /dev/null +++ b/backend/community/admin.py @@ -0,0 +1,404 @@ +from django.contrib import admin +from django.utils.html import format_html +from django.urls import path, reverse +from django.shortcuts import redirect +from unfold.admin import ModelAdmin, TabularInline +from unfold.decorators import display +from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement +from .admin_actions import export_signups_csv, export_signups_excel + +class ActivitySignupInline(TabularInline): + model = ActivitySignup + extra = 0 + readonly_fields = ('signup_time',) + fields = ('user', 'status', 'signup_time') + autocomplete_fields = ['user'] + can_delete = True + show_change_link = True + +class ReplyInline(TabularInline): + model = Reply + extra = 0 + readonly_fields = ('created_at',) + fields = ('content', 'author', 'created_at') + can_delete = True + show_change_link = True + +class TopicMediaInline(TabularInline): + model = TopicMedia + extra = 0 + fields = ('file', 'file_url', 'media_type', 'created_at') + readonly_fields = ('created_at',) + can_delete = True + +class OrderableAdminMixin: + """ + 为 Admin 添加排序功能的 Mixin + 提供上移、下移按钮,直接交换 order 值 + """ + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('/move-up/', self.admin_site.admin_view(self.move_up_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_up'), + path('/move-down/', self.admin_site.admin_view(self.move_down_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_down'), + ] + return custom_urls + urls + + def move_up_view(self, request, object_id): + obj = self.get_object(request, object_id) + if obj: + qs = self.model.objects.all() + # 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换 + if hasattr(obj, 'is_pinned'): + qs = qs.filter(is_pinned=obj.is_pinned) + + # 找到排在它前面的一个 (order 小于它的最大值) + prev_obj = qs.filter(order__lt=obj.order).order_by('-order').first() + if prev_obj: + # 交换 + obj.order, prev_obj.order = prev_obj.order, obj.order + obj.save() + prev_obj.save() + self.message_user(request, f"成功将 {obj} 上移") + else: + # 已经是第一个,或者前面没有更小的 order + pass + return redirect(request.META.get('HTTP_REFERER', '..')) + + def move_down_view(self, request, object_id): + obj = self.get_object(request, object_id) + if obj: + qs = self.model.objects.all() + # 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换 + if hasattr(obj, 'is_pinned'): + qs = qs.filter(is_pinned=obj.is_pinned) + + # 找到排在它后面的一个 (order 大于它的最小值) + next_obj = qs.filter(order__gt=obj.order).order_by('order').first() + if next_obj: + # 交换 + obj.order, next_obj.order = next_obj.order, obj.order + obj.save() + next_obj.save() + self.message_user(request, f"成功将 {obj} 下移") + return redirect(request.META.get('HTTP_REFERER', '..')) + + def order_actions(self, obj): + # 只有专家用户才显示排序按钮 + if not getattr(obj, 'is_star', True): # 默认为True是为了兼容其他模型,WeChatUser有is_star字段 + return "默认排序" + + # 使用 inline style 实现基本样式 + btn_style = ( + "display: inline-flex; align-items: center; justify-content: center; " + "width: 26px; height: 26px; border-radius: 6px; " + "background-color: #f3f4f6; color: #4b5563; text-decoration: none; " + "border: 1px solid #e5e7eb; transition: all 0.2s;" + ) + # onmouseover js + hover_js = "this.style.backgroundColor='#dbeafe'; this.style.color='#2563eb'; this.style.borderColor='#bfdbfe';" + out_js = "this.style.backgroundColor='#f3f4f6'; this.style.color='#4b5563'; this.style.borderColor='#e5e7eb';" + + return format_html( + '
' + '' + '' + '' + '{}' + '' + '' + '' + '
', + reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_up', args=[obj.pk]), + btn_style, hover_js, out_js, + obj.order, + reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_down', args=[obj.pk]), + btn_style, hover_js, out_js, + ) + order_actions.short_description = "排序调节" + order_actions.allow_tags = True + +@admin.register(Activity) +class ActivityAdmin(ModelAdmin): + list_display = ('title', 'author_info_display', 'banner_display', 'start_time', 'location', 'signup_count', 'is_visible', 'is_active', 'auto_confirm', 'created_at') + list_filter = ('is_visible', 'is_active', 'auto_confirm', 'start_time') + search_fields = ('title', 'location', 'author__phone_number') + # autocomplete_fields = ['author'] # 暂时注释,避免环境不一致导致报错 + raw_id_fields = ('author',) + inlines = [ActivitySignupInline] + + fieldsets = ( + ('基本信息', { + 'fields': ('title', 'author', 'description', 'banner', 'banner_url', 'is_visible', 'is_active', 'auto_confirm') + }), + ('费用与时间', { + 'fields': ('is_paid', 'price', 'start_time', 'end_time', 'location'), + 'classes': ('tab',) + }), + ('报名设置', { + 'fields': ('max_participants', 'ask_name', 'ask_phone', 'ask_wechat', 'ask_company', 'signup_form_config'), + 'description': '勾选需要收集的信息,或者在下方“自定义报名配置”中填写高级JSON配置' + }), + ) + + @display(description="发布者 (手机号/昵称)") + def author_info_display(self, obj): + if not obj.author: + return "-" + phone = obj.author.phone_number or "无手机号" + nickname = obj.author.nickname or "无昵称" + return f"{phone} ({nickname})" + + @display(description="Banner") + def banner_display(self, obj): + if obj.banner: + return format_html('', obj.banner.url) + elif obj.banner_url: + return format_html('', obj.banner_url) + return "暂无" + + @display(description="报名人数") + def signup_count(self, obj): + return obj.signups.count() + +@admin.register(ActivitySignup) +class ActivitySignupAdmin(ModelAdmin): + list_display = ('activity', 'user_info_display', 'signup_time', 'status_label', 'order_link') + list_filter = ('status', 'signup_time', 'activity') + search_fields = ('user__nickname', 'user__phone_number', 'activity__title') + autocomplete_fields = ['activity', 'user'] + actions = [export_signups_csv, export_signups_excel] + + fieldsets = ( + ('报名详情', { + 'fields': ('activity', 'user', 'status', 'order', 'signup_info_display') + }), + ('时间信息', { + 'fields': ('signup_time',), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('signup_time', 'signup_info_display') + + @display(description="报名用户 (手机号/昵称)") + def user_info_display(self, obj): + phone = obj.user.phone_number or "无手机号" + nickname = obj.user.nickname or "无昵称" + return f"{phone} ({nickname})" + + @display(description="报名信息") + def signup_info_display(self, obj): + import json + if not obj.signup_info: + return "无" + + try: + # Format JSON nicely + formatted_json = json.dumps(obj.signup_info, indent=2, ensure_ascii=False) + return format_html('
{}
', formatted_json) + except: + return str(obj.signup_info) + + @display( + description="状态", + label={ + "pending": "warning", + "confirmed": "success", + "cancelled": "danger", + "unpaid": "secondary", + } + ) + def status_label(self, obj): + # Auto sync with order status on display + if obj.check_payment_status(): + # If status changed, return new status + return obj.status + return obj.status + + @display(description="关联订单") + def order_link(self, obj): + if obj.order: + return format_html('Order #{}', obj.order.id, obj.order.id) + return "-" + +@admin.register(Topic) +class TopicAdmin(OrderableAdminMixin, ModelAdmin): + list_display = ('title', 'status', 'category', 'author_info_display', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at', 'order_actions') + list_filter = ('status', 'category', 'is_pinned', 'created_at', 'related_product', 'related_service', 'related_course') + search_fields = ('title', 'content', 'author__nickname', 'author__phone_number') + autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course'] + filter_horizontal = ('likes',) + inlines = [TopicMediaInline, ReplyInline] + actions = ['reset_ordering', 'approve_topics', 'reject_topics'] + list_editable = ('status', 'is_pinned', 'view_count') + + @display(description="作者 (手机号/昵称)") + def author_info_display(self, obj): + if not obj.author: + return "-" + phone = obj.author.phone_number or "无手机号" + nickname = obj.author.nickname or "无昵称" + return f"{phone} ({nickname})" + + @admin.action(description="批量通过审核") + def approve_topics(self, request, queryset): + rows_updated = queryset.update(status='published') + self.message_user(request, f"{rows_updated} 个帖子已通过审核") + + @admin.action(description="批量拒绝") + def reject_topics(self, request, queryset): + rows_updated = queryset.update(status='rejected') + self.message_user(request, f"{rows_updated} 个帖子已拒绝") + + def save_model(self, request, obj, form, change): + # 当帖子被置顶时(新建或修改状态),默认将排序值设为0 + if obj.is_pinned and (not change or 'is_pinned' in form.changed_data): + obj.order = 0 + super().save_model(request, obj, form, change) + + @admin.action(description="重置排序 (0,1,2... 新帖子在前)") + def reset_ordering(self, request, queryset): + """ + 将所有帖子按时间倒序重新分配order值 (0, 1, 2, ...) + """ + all_objects = Topic.objects.all().order_by('-created_at') + for index, obj in enumerate(all_objects): + if obj.order != index: + obj.order = index + obj.save(update_fields=['order']) + self.message_user(request, f"成功重置了 {all_objects.count()} 个帖子的排序权重(从0开始)。") + + fieldsets = ( + ('帖子内容', { + 'fields': ('title', 'status', 'category', 'content', 'is_pinned', 'likes') + }), + ('关联信息', { + 'fields': ('author', 'related_product', 'related_service', 'related_course'), + 'description': '可关联 硬件、服务 或 课程,用于技术求助或讨论' + }), + ('统计数据', { + 'fields': ('view_count', 'order', 'created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + readonly_fields = ('created_at', 'updated_at') + + @display(description="关联项目") + def get_related_item(self, obj): + if obj.related_product: + return f"[硬件] {obj.related_product.name}" + if obj.related_service: + return f"[服务] {obj.related_service.title}" + if obj.related_course: + return f"[课程] {obj.related_course.title}" + return "-" + + @display(description="回复数") + def reply_count(self, obj): + return obj.replies.count() + +@admin.register(Reply) +class ReplyAdmin(ModelAdmin): + list_display = ('short_content', 'topic', 'author_info_display', 'is_pinned', 'like_count', 'created_at') + list_filter = ('is_pinned', 'created_at') + search_fields = ('content', 'author__nickname', 'author__phone_number', 'topic__title') + autocomplete_fields = ['author', 'topic', 'reply_to'] + filter_horizontal = ('likes',) + list_editable = ('is_pinned',) + inlines = [TopicMediaInline] + + fieldsets = ( + ('回复内容', { + 'fields': ('topic', 'reply_to', 'content', 'likes') + }), + ('发布信息', { + 'fields': ('author', 'is_pinned', 'created_at') + }), + ) + readonly_fields = ('created_at',) + + @display(description="回复者 (手机号/昵称)") + def author_info_display(self, obj): + if not obj.author: + return "-" + phone = obj.author.phone_number or "无手机号" + nickname = obj.author.nickname or "无昵称" + return f"{phone} ({nickname})" + + @display(description="点赞数") + def like_count(self, obj): + return obj.likes.count() + + @display(description="内容摘要") + def short_content(self, obj): + return obj.content[:30] + '...' if len(obj.content) > 30 else obj.content + +@admin.register(TopicMedia) +class TopicMediaAdmin(ModelAdmin): + list_display = ('id', 'media_type', 'file_preview', 'topic', 'reply', 'created_at') + list_filter = ('media_type', 'created_at') + search_fields = ('file', 'topic__title') + autocomplete_fields = ['topic', 'reply'] + + @display(description="预览") + def file_preview(self, obj): + url = "" + if obj.file: + url = obj.file.url + elif obj.file_url: + url = obj.file_url + + if obj.media_type == 'image' and url: + return format_html('', url) + return obj.file.name or "外部文件" + +@admin.register(Announcement) +class AnnouncementAdmin(ModelAdmin): + list_display = ('title', 'image_preview', 'active_label', 'pinned_label', 'priority', 'start_time', 'end_time', 'created_at') + list_filter = ('is_active', 'is_pinned', 'created_at') + search_fields = ('title', 'content') + + fieldsets = ( + ('公告信息', { + 'fields': ('title', 'content', 'link_url') + }), + ('图片设置', { + 'fields': ('image', 'image_url'), + 'description': '上传图片或填写图片链接,优先显示上传的图片' + }), + ('显示设置', { + 'fields': ('is_active', 'is_pinned', 'priority'), + 'classes': ('tab',) + }), + ('排期设置', { + 'fields': ('start_time', 'end_time'), + 'classes': ('tab',) + }), + ) + + @display(description="图片预览") + def image_preview(self, obj): + url = obj.display_image_url + if url: + return format_html('', url) + return "无图片" + + @display( + description="状态", + label={ + True: "success", + False: "danger", + } + ) + def active_label(self, obj): + return obj.is_active + + @display( + description="置顶", + label={ + True: "warning", + False: "default", + } + ) + def pinned_label(self, obj): + return obj.is_pinned diff --git a/backend/community/admin_actions.py b/backend/community/admin_actions.py new file mode 100644 index 0000000..72146c8 --- /dev/null +++ b/backend/community/admin_actions.py @@ -0,0 +1,149 @@ +import csv +import json +import datetime +from django.http import HttpResponse +from django.utils.encoding import escape_uri_path + +def flatten_json(y): + """ + Flatten a nested json object + """ + out = {} + + def flatten(x, name=''): + if type(x) is dict: + for a in x: + flatten(x[a], name + a + '_') + elif type(x) is list: + i = 0 + for a in x: + flatten(a, name + str(i) + '_') + i += 1 + else: + out[name[:-1]] = x + + flatten(y) + return out + +def get_signup_info_keys(queryset): + """ + Collect all unique keys from the signup_info JSON across the queryset + """ + keys = set() + for obj in queryset: + if obj.signup_info and isinstance(obj.signup_info, dict): + # Flatten the dictionary first to get all nested keys + flat_info = flatten_json(obj.signup_info) + keys.update(flat_info.keys()) + return sorted(list(keys)) + +def export_signups_csv(modeladmin, request, queryset): + """ + Export selected signups to CSV, including flattened JSON fields + """ + opts = modeladmin.model._meta + filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + response = HttpResponse(content_type='text/csv; charset=utf-8-sig') + response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}' + + writer = csv.writer(response) + + # Base fields to export + base_headers = ['ID', '活动标题', '用户昵称', '用户ID', '报名时间', '状态', '关联订单ID'] + + # Get dynamic JSON keys + json_keys = get_signup_info_keys(queryset) + + # Write header + writer.writerow(base_headers + json_keys) + + # Write data + for obj in queryset: + row = [ + str(obj.id), + obj.activity.title, + obj.user.nickname if obj.user else 'Unknown', + str(obj.user.id) if obj.user else '', + obj.signup_time.strftime('%Y-%m-%d %H:%M:%S'), + obj.get_status_display(), + str(obj.order.id) if obj.order else '' + ] + + # Add JSON data + flat_info = {} + if obj.signup_info and isinstance(obj.signup_info, dict): + flat_info = flatten_json(obj.signup_info) + + for key in json_keys: + val = flat_info.get(key, '') + if val is None: + val = '' + row.append(str(val)) + + writer.writerow(row) + + return response + +export_signups_csv.short_description = "导出选中报名记录为 CSV (含详细信息)" + +def export_signups_excel(modeladmin, request, queryset): + """ + Export selected signups to Excel, including flattened JSON fields + """ + try: + from openpyxl import Workbook + except ImportError: + modeladmin.message_user(request, "请先安装 openpyxl 库以使用 Excel 导出功能", level='error') + return + + opts = modeladmin.model._meta + filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + response = HttpResponse( + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ) + response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}' + + wb = Workbook() + ws = wb.active + ws.title = str(opts.verbose_name)[:31] # Sheet name limit is 31 chars + + # Base fields to export + base_headers = ['ID', '活动标题', '用户昵称', '用户ID', '报名时间', '状态', '关联订单ID'] + + # Get dynamic JSON keys + json_keys = get_signup_info_keys(queryset) + + # Write header + ws.append(base_headers + json_keys) + + # Write data + for obj in queryset: + row = [ + obj.id, + obj.activity.title, + obj.user.nickname if obj.user else 'Unknown', + obj.user.id if obj.user else '', + obj.signup_time.replace(tzinfo=None) if obj.signup_time else '', # Remove tz for Excel + obj.get_status_display(), + obj.order.id if obj.order else '' + ] + + # Add JSON data + flat_info = {} + if obj.signup_info and isinstance(obj.signup_info, dict): + flat_info = flatten_json(obj.signup_info) + + for key in json_keys: + val = flat_info.get(key, '') + if val is None: + val = '' + row.append(str(val)) # Ensure string for simplicity, or handle types + + ws.append(row) + + wb.save(response) + return response + +export_signups_excel.short_description = "导出选中报名记录为 Excel (含详细信息)" diff --git a/backend/community/apps.py b/backend/community/apps.py new file mode 100644 index 0000000..2ab4c53 --- /dev/null +++ b/backend/community/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CommunityConfig(AppConfig): + name = 'community' diff --git a/backend/community/migrations/0001_initial.py b/backend/community/migrations/0001_initial.py new file mode 100644 index 0000000..86d3205 --- /dev/null +++ b/backend/community/migrations/0001_initial.py @@ -0,0 +1,144 @@ +# Generated by Django 6.0.1 on 2026-03-04 04:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('shop', '__first__'), + ] + + operations = [ + migrations.CreateModel( + name='Announcement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='公告标题')), + ('content', models.TextField(verbose_name='公告内容')), + ('image', models.ImageField(blank=True, null=True, upload_to='announcements/', verbose_name='公告图片')), + ('image_url', models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='图片链接')), + ('link_url', models.URLField(blank=True, null=True, verbose_name='跳转链接')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ('is_pinned', models.BooleanField(default=False, verbose_name='是否置顶')), + ('priority', models.IntegerField(default=0, help_text='数字越大越靠前', verbose_name='排序权重')), + ('start_time', models.DateTimeField(blank=True, null=True, verbose_name='开始展示时间')), + ('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束展示时间')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '社区公告', + 'verbose_name_plural': '社区公告管理', + 'ordering': ['-is_pinned', '-priority', '-created_at'], + }, + ), + migrations.CreateModel( + name='Activity', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='活动标题')), + ('description', models.TextField(verbose_name='活动详情')), + ('banner', models.ImageField(blank=True, null=True, upload_to='activities/banners/', verbose_name='活动Banner图')), + ('banner_url', models.URLField(blank=True, help_text='可直接填写图片链接,若同时上传图片,将优先显示上传的图片', null=True, verbose_name='活动Banner链接')), + ('start_time', models.DateTimeField(verbose_name='开始时间')), + ('end_time', models.DateTimeField(verbose_name='结束时间')), + ('location', models.CharField(max_length=100, verbose_name='活动地点')), + ('max_participants', models.IntegerField(default=50, verbose_name='最大报名人数')), + ('is_paid', models.BooleanField(default=False, verbose_name='是否收费')), + ('price', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='报名费用')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ('is_visible', models.BooleanField(default=True, help_text='关闭后将不在前端列表页显示', verbose_name='是否显示')), + ('auto_confirm', models.BooleanField(default=False, help_text='开启后,付费活动支付成功或免费活动报名后直接显示报名成功,不需要审核', verbose_name='无需审核')), + ('ask_name', models.BooleanField(default=False, verbose_name='收集姓名')), + ('ask_phone', models.BooleanField(default=False, verbose_name='收集手机号')), + ('ask_wechat', models.BooleanField(default=False, verbose_name='收集微信号')), + ('ask_company', models.BooleanField(default=False, verbose_name='收集公司/机构')), + ('signup_form_config', models.JSONField(blank=True, default=list, help_text='JSON格式的高级配置,若填写则优先于上方开关。例如:[{"name": "job", "label": "职位", "type": "text", "required": true}]', verbose_name='自定义报名配置')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ], + options={ + 'verbose_name': '社区活动', + 'verbose_name_plural': '社区活动管理', + }, + ), + migrations.CreateModel( + name='Topic', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='标题')), + ('category', models.CharField(choices=[('discussion', '技术讨论'), ('help', '求助问答'), ('share', '经验分享'), ('notice', '官方公告')], default='discussion', max_length=20, verbose_name='分类')), + ('status', models.CharField(choices=[('pending', '待审核'), ('published', '已发布'), ('rejected', '已拒绝')], default='published', max_length=20, verbose_name='状态')), + ('content', models.TextField(help_text='支持Markdown格式,支持插入图片', verbose_name='内容')), + ('view_count', models.IntegerField(default=0, verbose_name='浏览量')), + ('is_pinned', models.BooleanField(default=False, verbose_name='置顶')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='发布时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('order', models.IntegerField(default=0, verbose_name='排序')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='topics', to='shop.wechatuser', verbose_name='作者')), + ('likes', models.ManyToManyField(blank=True, related_name='liked_topics', to='shop.wechatuser', verbose_name='点赞用户')), + ('related_course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.vccourse', verbose_name='关联课程')), + ('related_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.esp32config', verbose_name='关联硬件')), + ('related_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.service', verbose_name='关联服务')), + ], + options={ + 'verbose_name': '论坛帖子', + 'verbose_name_plural': '论坛帖子管理', + 'ordering': ['order', '-is_pinned', '-created_at'], + }, + ), + migrations.CreateModel( + name='Reply', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField(help_text='支持Markdown格式', verbose_name='回复内容')), + ('is_pinned', models.BooleanField(default=False, verbose_name='置顶')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='回复时间')), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='shop.wechatuser', verbose_name='回复者')), + ('likes', models.ManyToManyField(blank=True, related_name='liked_replies', to='shop.wechatuser', verbose_name='点赞用户')), + ('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='community.reply', verbose_name='回复楼层')), + ('topic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='community.topic', verbose_name='所属帖子')), + ], + options={ + 'verbose_name': '帖子回复', + 'verbose_name_plural': '帖子回复管理', + 'ordering': ['-is_pinned', '-created_at'], + }, + ), + migrations.CreateModel( + name='TopicMedia', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(blank=True, null=True, upload_to='community/media/', verbose_name='文件')), + ('file_url', models.URLField(blank=True, max_length=500, null=True, verbose_name='文件链接')), + ('media_type', models.CharField(choices=[('image', '图片'), ('video', '视频'), ('file', '文件')], default='image', max_length=10, verbose_name='媒体类型')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='上传时间')), + ('reply', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media', to='community.reply', verbose_name='所属回复')), + ('topic', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media', to='community.topic', verbose_name='所属帖子')), + ], + options={ + 'verbose_name': '论坛媒体资源', + 'verbose_name_plural': '论坛媒体资源管理', + }, + ), + migrations.CreateModel( + name='ActivitySignup', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('signup_time', models.DateTimeField(auto_now_add=True, verbose_name='报名时间')), + ('signup_info', models.JSONField(blank=True, default=dict, verbose_name='报名信息')), + ('status', models.CharField(choices=[('unpaid', '待支付'), ('pending', '审核中'), ('confirmed', '报名成功'), ('cancelled', '已取消')], default='confirmed', max_length=20, verbose_name='状态')), + ('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='signups', to='community.activity', verbose_name='活动')), + ('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_signups', to='shop.order', verbose_name='关联订单')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_signups', to='shop.wechatuser', verbose_name='报名用户')), + ], + options={ + 'verbose_name': '活动报名', + 'verbose_name_plural': '活动报名管理', + 'unique_together': {('activity', 'user')}, + }, + ), + ] diff --git a/backend/community/migrations/0002_activity_author.py b/backend/community/migrations/0002_activity_author.py new file mode 100644 index 0000000..4990fdb --- /dev/null +++ b/backend/community/migrations/0002_activity_author.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.1 on 2026-03-04 04:58 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('community', '0001_initial'), + ('shop', '__first__'), + ] + + operations = [ + migrations.AddField( + model_name='activity', + name='author', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to='shop.wechatuser', verbose_name='发布者'), + ), + ] diff --git a/backend/community/migrations/0003_alter_activity_author.py b/backend/community/migrations/0003_alter_activity_author.py new file mode 100644 index 0000000..3b687ee --- /dev/null +++ b/backend/community/migrations/0003_alter_activity_author.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.1 on 2026-03-17 11:11 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('community', '0002_activity_author'), + ('shop', '0039_vccourse_video_embed_code'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='author', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to='shop.wechatuser', to_field='phone_number', verbose_name='发布者'), + ), + ] diff --git a/backend/community/migrations/0004_alter_activity_id_alter_activitysignup_id_and_more.py b/backend/community/migrations/0004_alter_activity_id_alter_activitysignup_id_and_more.py new file mode 100644 index 0000000..cb8f8d9 --- /dev/null +++ b/backend/community/migrations/0004_alter_activity_id_alter_activitysignup_id_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 4.2.29 on 2026-03-18 12:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('community', '0003_alter_activity_author'), + ] + + operations = [ + migrations.AlterField( + model_name='activity', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='activitysignup', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='announcement', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='reply', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='topic', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='topicmedia', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/backend/community/migrations/__init__.py b/backend/community/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/community/models.py b/backend/community/models.py new file mode 100644 index 0000000..e8a1800 --- /dev/null +++ b/backend/community/models.py @@ -0,0 +1,285 @@ +from django.db import models +from shop.models import WeChatUser, ESP32Config, Order, Service, VCCourse, ServiceOrder + +class Activity(models.Model): + """ + 社区活动模型 + """ + title = models.CharField(max_length=100, verbose_name="活动标题") + description = models.TextField(verbose_name="活动详情") + banner = models.ImageField(upload_to='activities/banners/', verbose_name="活动Banner图", null=True, blank=True) + banner_url = models.URLField(verbose_name="活动Banner链接", null=True, blank=True, help_text="可直接填写图片链接,若同时上传图片,将优先显示上传的图片") + start_time = models.DateTimeField(verbose_name="开始时间") + end_time = models.DateTimeField(verbose_name="结束时间") + location = models.CharField(max_length=100, verbose_name="活动地点") + max_participants = models.IntegerField(default=50, verbose_name="最大报名人数") + + # 费用设置 + is_paid = models.BooleanField(default=False, verbose_name="是否收费") + price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="报名费用") + + author = models.ForeignKey(WeChatUser, to_field='phone_number', on_delete=models.SET_NULL, related_name='activities', verbose_name="发布者", null=True, blank=True) + + is_active = models.BooleanField(default=True, verbose_name="是否启用") + is_visible = models.BooleanField(default=True, verbose_name="是否显示", help_text="关闭后将不在前端列表页显示") + auto_confirm = models.BooleanField(default=False, verbose_name="无需审核", help_text="开启后,付费活动支付成功或免费活动报名后直接显示报名成功,不需要审核") + + # 常用报名信息开关 + ask_name = models.BooleanField(default=False, verbose_name="收集姓名") + ask_phone = models.BooleanField(default=False, verbose_name="收集手机号") + ask_wechat = models.BooleanField(default=False, verbose_name="收集微信号") + ask_company = models.BooleanField(default=False, verbose_name="收集公司/机构") + + signup_form_config = models.JSONField( + default=list, + verbose_name="自定义报名配置", + blank=True, + help_text='JSON格式的高级配置,若填写则优先于上方开关。例如:[{"name": "job", "label": "职位", "type": "text", "required": true}]' + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + + def clean(self): + from django.core.exceptions import ValidationError + if not self.banner and not self.banner_url: + raise ValidationError("Banner图片和Banner链接必须至少填写一项") + + def save(self, *args, **kwargs): + self.clean() + super().save(*args, **kwargs) + + @property + def display_banner_url(self): + """ + 获取Banner显示的URL,优先使用上传的图片 + """ + if self.banner: + return self.banner.url + return self.banner_url + + @property + def current_signups(self): + """ + 当前有效报名人数(仅统计已确认/已支付的报名) + """ + return self.signups.filter(status='confirmed').count() + + def __str__(self): + return self.title + + class Meta: + verbose_name = "社区活动" + verbose_name_plural = "社区活动管理" + + +class ActivitySignup(models.Model): + """ + 活动报名记录 + """ + STATUS_CHOICES = ( + ('unpaid', '待支付'), + ('pending', '审核中'), + ('confirmed', '报名成功'), + ('cancelled', '已取消'), + ) + + activity = models.ForeignKey(Activity, on_delete=models.CASCADE, related_name='signups', verbose_name="活动") + user = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='activity_signups', verbose_name="报名用户") + signup_time = models.DateTimeField(auto_now_add=True, verbose_name="报名时间") + signup_info = models.JSONField( + default=dict, + verbose_name="报名信息", + blank=True + ) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='confirmed', verbose_name="状态") + + # 关联订单(针对付费活动) + order = models.ForeignKey('shop.Order', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联订单", related_name='activity_signups') + + def __str__(self): + return f"{self.user.nickname} - {self.activity.title}" + + def check_payment_status(self): + """ + 检查并同步关联订单的支付状态 + """ + if self.status == 'unpaid' and self.order: + if self.order.status == 'paid': + self.status = 'confirmed' if self.activity.auto_confirm else 'pending' + self.save() + return True + return False + + class Meta: + verbose_name = "活动报名" + verbose_name_plural = "活动报名管理" + unique_together = ('activity', 'user') + + +class Topic(models.Model): + """ + 论坛帖子/主题 + """ + title = models.CharField(max_length=200, verbose_name="标题") + + CATEGORY_CHOICES = ( + ('discussion', '技术讨论'), + ('help', '求助问答'), + ('share', '经验分享'), + ('notice', '官方公告'), + ) + category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='discussion', verbose_name="分类") + + STATUS_CHOICES = ( + ('pending', '待审核'), + ('published', '已发布'), + ('rejected', '已拒绝'), + ) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='published', verbose_name="状态") + + content = models.TextField(verbose_name="内容", help_text="支持Markdown格式,支持插入图片") + author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='topics', verbose_name="作者") + + # 关联对象:硬件、服务、课程 + related_product = models.ForeignKey(ESP32Config, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联硬件") + related_service = models.ForeignKey(Service, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联服务") + related_course = models.ForeignKey(VCCourse, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联课程") + + view_count = models.IntegerField(default=0, verbose_name="浏览量") + likes = models.ManyToManyField(WeChatUser, related_name='liked_topics', blank=True, verbose_name="点赞用户") + is_pinned = models.BooleanField(default=False, verbose_name="置顶") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="发布时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + order = models.IntegerField(default=0, verbose_name="排序") + + def save(self, *args, **kwargs): + # 记录是否为新对象,因为super().save后pk就有了 + is_new = self.pk is None + + # 第一次保存,先入库 + super().save(*args, **kwargs) + + # 如果是新创建,且 order 默认为 0(未指定) + if is_new and getattr(self, 'order', 0) == 0: + # 将所有其他帖子的 order + 1,腾出 0 的位置 + Topic.objects.exclude(pk=self.pk).filter(order__gte=0).update(order=models.F('order') + 1) + # 确保自己是 0 + Topic.objects.filter(pk=self.pk).update(order=0) + self.order = 0 + + def __str__(self): + return self.title + + @property + def is_verified_owner(self): + """ + 判断作者是否为关联项目(硬件/服务/课程)的已购用户(Verified Owner) + """ + # 1. 验证硬件 + if self.related_product: + if Order.objects.filter( + wechat_user=self.author, + config=self.related_product, + status__in=['paid', 'shipped'] + ).exists(): + return True + + # 2. 验证课程 + if self.related_course: + if Order.objects.filter( + wechat_user=self.author, + course=self.related_course, + status__in=['paid', 'shipped'] + ).exists(): + return True + + # 3. 验证服务 + if self.related_service: + pass + + return False + + class Meta: + verbose_name = "论坛帖子" + verbose_name_plural = "论坛帖子管理" + ordering = ['order', '-is_pinned', '-created_at'] + + +class Reply(models.Model): + """ + 帖子回复 + """ + topic = models.ForeignKey(Topic, on_delete=models.CASCADE, related_name='replies', verbose_name="所属帖子") + content = models.TextField(verbose_name="回复内容", help_text="支持Markdown格式") + author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='replies', verbose_name="回复者") + reply_to = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="回复楼层") + likes = models.ManyToManyField(WeChatUser, related_name='liked_replies', blank=True, verbose_name="点赞用户") + is_pinned = models.BooleanField(default=False, verbose_name="置顶") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="回复时间") + + def __str__(self): + return f"回复: {self.topic.title}" + + class Meta: + verbose_name = "帖子回复" + verbose_name_plural = "帖子回复管理" + ordering = ['-is_pinned', '-created_at'] + + +class TopicMedia(models.Model): + """ + 论坛多媒体资源(图片/视频/文件) + """ + MEDIA_TYPE_CHOICES = ( + ('image', '图片'), + ('video', '视频'), + ('file', '文件'), + ) + + topic = models.ForeignKey(Topic, on_delete=models.CASCADE, related_name='media', verbose_name="所属帖子", null=True, blank=True) + reply = models.ForeignKey(Reply, on_delete=models.CASCADE, related_name='media', verbose_name="所属回复", null=True, blank=True) + file = models.FileField(upload_to='community/media/', verbose_name="文件", null=True, blank=True) + file_url = models.URLField(max_length=500, verbose_name="文件链接", null=True, blank=True) + media_type = models.CharField(max_length=10, choices=MEDIA_TYPE_CHOICES, default='image', verbose_name="媒体类型") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间") + + def __str__(self): + return f"{self.media_type} - {self.file.name}" + + class Meta: + verbose_name = "论坛媒体资源" + verbose_name_plural = "论坛媒体资源管理" + + +class Announcement(models.Model): + """ + 社区公告模型 + """ + title = models.CharField(max_length=100, verbose_name="公告标题") + content = models.TextField(verbose_name="公告内容") + image = models.ImageField(upload_to='announcements/', verbose_name="公告图片", null=True, blank=True) + image_url = models.URLField(verbose_name="图片链接", null=True, blank=True, help_text="优先使用上传的图片") + link_url = models.URLField(verbose_name="跳转链接", null=True, blank=True) + + is_active = models.BooleanField(default=True, verbose_name="是否启用") + is_pinned = models.BooleanField(default=False, verbose_name="是否置顶") + priority = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越大越靠前") + + start_time = models.DateTimeField(verbose_name="开始展示时间", null=True, blank=True) + end_time = models.DateTimeField(verbose_name="结束展示时间", null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + @property + def display_image_url(self): + if self.image: + return self.image.url + return self.image_url + + def __str__(self): + return self.title + + class Meta: + verbose_name = "社区公告" + verbose_name_plural = "社区公告管理" + ordering = ['-is_pinned', '-priority', '-created_at'] diff --git a/backend/community/permissions.py b/backend/community/permissions.py new file mode 100644 index 0000000..0ec142e --- /dev/null +++ b/backend/community/permissions.py @@ -0,0 +1,23 @@ +from rest_framework import permissions +from .utils import get_current_wechat_user + +class IsAuthorOrReadOnly(permissions.BasePermission): + """ + Object-level permission to only allow authors of an object to edit it. + Assumes the model instance has an `author` attribute. + """ + + def has_object_permission(self, request, view, obj): + # Read permissions are allowed to any request, + # so we'll always allow GET, HEAD or OPTIONS requests. + if request.method in permissions.SAFE_METHODS: + return True + + # Write permissions are only allowed to the author of the object. + # We need to manually get the user because we are using custom auth logic (get_current_wechat_user) + # instead of request.user for some reason (or in addition to). + # However, DRF's request.user might not be set if we don't use a standard authentication class. + # Based on views.py, it uses `get_current_wechat_user(request)`. + + current_user = get_current_wechat_user(request) + return current_user and obj.author == current_user diff --git a/backend/community/serializers.py b/backend/community/serializers.py new file mode 100644 index 0000000..6dd079f --- /dev/null +++ b/backend/community/serializers.py @@ -0,0 +1,190 @@ +from rest_framework import serializers +from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement +from shop.serializers import WeChatUserSerializer, ESP32ConfigSerializer, ServiceSerializer, VCCourseSerializer +from .utils import get_current_wechat_user + +class ActivitySerializer(serializers.ModelSerializer): + display_banner_url = serializers.ReadOnlyField() + signup_form_config = serializers.SerializerMethodField() + current_signups = serializers.IntegerField(read_only=True) + has_signed_up = serializers.SerializerMethodField() + is_signed_up = serializers.SerializerMethodField() + my_signup_status = serializers.SerializerMethodField() + + class Meta: + model = Activity + fields = '__all__' + + def get_has_signed_up(self, obj): + return self.get_is_signed_up(obj) + + def get_my_signup_status(self, obj): + request = self.context.get('request') + if not request: + return None + user = get_current_wechat_user(request) + if user: + # Return the status of the non-cancelled signup + signup = obj.signups.filter(user=user).exclude(status='cancelled').first() + return signup.status if signup else None + return None + + def get_is_signed_up(self, obj): + request = self.context.get('request') + if not request: + return False + user = get_current_wechat_user(request) + if user: + # Check if there is a valid signup (only confirmed counts) + return obj.signups.filter(user=user, status='confirmed').exists() + return False + + def get_signup_form_config(self, obj): + # 1. 优先使用 JSON 配置 + if obj.signup_form_config: + return obj.signup_form_config + + # 2. 否则根据开关生成默认配置 + config = [] + if obj.ask_name: + config.append({"name": "name", "label": "姓名", "type": "text", "required": True}) + if obj.ask_phone: + config.append({"name": "phone", "label": "手机号", "type": "number", "required": True}) + if obj.ask_wechat: + config.append({"name": "wechat", "label": "微信号", "type": "text", "required": True}) + if obj.ask_company: + config.append({"name": "company", "label": "公司/机构", "type": "text", "required": False}) + + return config + +class ActivitySignupSerializer(serializers.ModelSerializer): + activity_info = serializers.SerializerMethodField() + + class Meta: + model = ActivitySignup + fields = ['id', 'activity', 'activity_info', 'user', 'signup_time', 'status', 'signup_info'] + read_only_fields = ['signup_time', 'status', 'user'] + + def get_activity_info(self, obj): + return ActivitySerializer(obj.activity).data + +class TopicMediaSerializer(serializers.ModelSerializer): + url = serializers.SerializerMethodField() + + class Meta: + model = TopicMedia + fields = ['id', 'file', 'file_url', 'url', 'media_type', 'created_at'] + + def get_url(self, obj): + if obj.file: + return obj.file.url + return obj.file_url + +class ReplySerializer(serializers.ModelSerializer): + author_info = WeChatUserSerializer(source='author', read_only=True) + media = TopicMediaSerializer(many=True, read_only=True) + media_ids = serializers.ListField( + child=serializers.IntegerField(), + write_only=True, + required=False + ) + like_count = serializers.IntegerField(source='likes.count', read_only=True) + is_liked = serializers.SerializerMethodField() + + class Meta: + model = Reply + fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at', 'media_ids', 'is_pinned', 'like_count', 'is_liked'] + read_only_fields = ['author', 'created_at'] + + def get_is_liked(self, obj): + request = self.context.get('request') + if not request: + return False + user = get_current_wechat_user(request) + if user: + return obj.likes.filter(id=user.id).exists() + return False + + def create(self, validated_data): + media_ids = validated_data.pop('media_ids', []) + reply = super().create(validated_data) + if media_ids: + TopicMedia.objects.filter(id__in=media_ids).update(reply=reply) + return reply + +class TopicSerializer(serializers.ModelSerializer): + author_info = WeChatUserSerializer(source='author', read_only=True) + replies = ReplySerializer(many=True, read_only=True) + media = TopicMediaSerializer(many=True, read_only=True) + is_verified_owner = serializers.BooleanField(read_only=True) + like_count = serializers.IntegerField(source='likes.count', read_only=True) + is_liked = serializers.SerializerMethodField() + + product_info = ESP32ConfigSerializer(source='related_product', read_only=True) + service_info = ServiceSerializer(source='related_service', read_only=True) + course_info = VCCourseSerializer(source='related_course', read_only=True) + + media_ids = serializers.ListField( + child=serializers.IntegerField(), + write_only=True, + required=False + ) + + class Meta: + model = Topic + fields = [ + 'id', 'title', 'category', 'status', 'content', 'author', 'author_info', + 'related_product', 'product_info', + 'related_service', 'service_info', + 'related_course', 'course_info', + 'view_count', 'is_pinned', 'created_at', 'updated_at', + 'is_verified_owner', 'replies', 'media', 'media_ids', + 'like_count', 'is_liked' + ] + read_only_fields = ['author', 'view_count', 'created_at', 'updated_at', 'is_verified_owner', 'status'] + + def get_is_liked(self, obj): + request = self.context.get('request') + if not request: + return False + user = get_current_wechat_user(request) + if user: + return obj.likes.filter(id=user.id).exists() + return False + + def create(self, validated_data): + media_ids = validated_data.pop('media_ids', []) + topic = super().create(validated_data) + if media_ids: + TopicMedia.objects.filter(id__in=media_ids).update(topic=topic) + return topic + +class AnnouncementSerializer(serializers.ModelSerializer): + display_image_url = serializers.ReadOnlyField() + + class Meta: + model = Announcement + fields = '__all__' + +class AdminActivitySerializer(serializers.ModelSerializer): + signup_form_config = serializers.JSONField(required=False) + description = serializers.CharField( + style={'base_template': 'textarea.html'}, + help_text="活动详情内容,支持 Markdown 格式。如果需要插入图片,请先调用媒体上传接口获取 URL。" + ) + + class Meta: + model = Activity + fields = '__all__' + read_only_fields = ['author', 'created_at', 'current_signups'] + +class AdminTopicSerializer(serializers.ModelSerializer): + content = serializers.CharField( + style={'base_template': 'textarea.html'}, + help_text="帖子内容,支持 Markdown 格式。如果需要插入图片,请先调用媒体上传接口获取 URL。" + ) + + class Meta: + model = Topic + fields = '__all__' + read_only_fields = ['author', 'created_at', 'updated_at', 'view_count', 'is_verified_owner'] diff --git a/backend/community/tests.py b/backend/community/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/community/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/community/urls.py b/backend/community/urls.py new file mode 100644 index 0000000..9f9f180 --- /dev/null +++ b/backend/community/urls.py @@ -0,0 +1,15 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import ActivityViewSet, TopicViewSet, ReplyViewSet, TopicMediaViewSet, AnnouncementViewSet, AdminPublishViewSet + +router = DefaultRouter() +router.register(r'activities', ActivityViewSet) +router.register(r'topics', TopicViewSet) +router.register(r'replies', ReplyViewSet) +router.register(r'media', TopicMediaViewSet, basename='media') +router.register(r'announcements', AnnouncementViewSet) +router.register(r'admin-publish', AdminPublishViewSet, basename='admin-publish') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/backend/community/utils.py b/backend/community/utils.py new file mode 100644 index 0000000..516961a --- /dev/null +++ b/backend/community/utils.py @@ -0,0 +1,55 @@ +from django.core.signing import TimestampSigner, BadSignature, SignatureExpired +from shop.models import WeChatUser +import logging + +logger = logging.getLogger(__name__) + +def get_current_wechat_user(request): + """ + 根据 Authorization 头获取当前微信用户 + 增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除), + 但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。 + """ + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + logger.warning(f"Authentication failed: Missing or invalid Authorization header. Header: {auth_header}") + return None + token = auth_header.split(' ')[1] + signer = TimestampSigner() + try: + # 签名包含 openid + openid = signer.unsign(token, max_age=86400 * 30) # 30天有效 + user = WeChatUser.objects.filter(openid=openid).first() + + if user: + return user + + # 如果没找到用户,检查是否是 Web 虚拟 OpenID + # 场景:Web 用户已被合并到小程序账号,旧 Web Token 依然有效,指向合并后的账号 + logger.info(f"User not found for openid: {openid}, checking for merged account...") + if openid.startswith('web_'): + try: + # 格式: web_13800138000 + parts = openid.split('_', 1) + if len(parts) == 2: + phone = parts[1] + # 尝试通过手机号查找(查找合并后的主账号) + user = WeChatUser.objects.filter(phone_number=phone).first() + if user: + logger.info(f"Found merged user {user.id} for phone {phone}") + return user + except Exception as e: + logger.error(f"Error checking merged account: {e}") + pass + + logger.warning(f"Authentication failed: User not found for openid {openid}") + return None + except SignatureExpired: + logger.warning("Authentication failed: Signature expired") + return None + except BadSignature: + logger.warning("Authentication failed: Bad signature") + return None + except Exception as e: + logger.error(f"Authentication unexpected error: {e}") + return None diff --git a/backend/community/views.py b/backend/community/views.py new file mode 100644 index 0000000..5511cc4 --- /dev/null +++ b/backend/community/views.py @@ -0,0 +1,516 @@ +from rest_framework import viewsets, status, mixins, parsers, filters +from django_filters.rest_framework import DjangoFilterBackend +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework import serializers, permissions +from django.core.signing import TimestampSigner, BadSignature, SignatureExpired +from django.utils import timezone +from django.db import models +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes + +from shop.models import WeChatUser, Order +from shop.views import get_wechat_pay_client +from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement +from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer, AdminActivitySerializer, AdminTopicSerializer +from .utils import get_current_wechat_user +from .permissions import IsAuthorOrReadOnly + +class ActivityViewSet(viewsets.ReadOnlyModelViewSet): + """ + 社区活动接口 + """ + queryset = Activity.objects.filter(is_active=True).order_by('-created_at') + serializer_class = ActivitySerializer + + def get_queryset(self): + qs = super().get_queryset() + # list 接口过滤 is_visible=True + if self.action == 'list': + qs = qs.filter(is_visible=True) + return qs + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + + # Sync status for current user + user = get_current_wechat_user(request) + if user: + # Use filter to avoid exception if multiple exist (though unique_together constraint exists) + signup = instance.signups.filter(user=user).exclude(status='cancelled').first() + if signup: + has_changed = signup.check_payment_status() + if has_changed: + print(f"DEBUG: Synced signup status for user {user.id} activity {instance.id}") + + serializer = self.get_serializer(instance) + # Debug print to verify data + print(f"DEBUG: Activity {instance.title} current_signups: {instance.current_signups}") + return Response(serializer.data) + + @extend_schema(summary="报名活动") + @action(detail=True, methods=['post']) + def signup(self, request, pk=None): + user = get_current_wechat_user(request) + if not user: + return Response({'error': '请先登录'}, status=401) + + activity = self.get_object() + + # 1. Check confirmed signup + if ActivitySignup.objects.filter(activity=activity, user=user, status='confirmed').exists(): + return Response({'error': '您已报名该活动'}, status=400) + + # 2. Get pending signup (for retry) + pending_signup = ActivitySignup.objects.filter(activity=activity, user=user, status='pending').first() + + # 3. Check limit (exclude cancelled, exclude current pending) + query = activity.signups.exclude(status='cancelled') + if pending_signup: + query = query.exclude(id=pending_signup.id) + + if query.count() >= activity.max_participants: + return Response({'error': '活动名额已满'}, status=400) + + # Get signup info + signup_info = request.data.get('signup_info', {}) + + # Validate signup info + effective_config = activity.signup_form_config + if not effective_config: + effective_config = [] + if activity.ask_name: + effective_config.append({"name": "name", "label": "姓名", "type": "text", "required": True}) + if activity.ask_phone: + effective_config.append({"name": "phone", "label": "手机号", "type": "number", "required": True}) + if activity.ask_wechat: + effective_config.append({"name": "wechat", "label": "微信号", "type": "text", "required": True}) + if activity.ask_company: + effective_config.append({"name": "company", "label": "公司/机构", "type": "text", "required": False}) + + if effective_config: + required_fields = [f['name'] for f in effective_config if f.get('required')] + for field in required_fields: + val = signup_info.get(field) + if val is None or (isinstance(val, str) and not val.strip()): + label = next((f['label'] for f in effective_config if f['name'] == field), field) + return Response({'error': f'请填写: {label}'}, status=400) + + # Handle Payment Logic + if activity.is_paid and activity.price > 0: + import time + from wechatpayv3 import WeChatPayType + + # Create or Get Order + order = None + if pending_signup and pending_signup.order: + # Reuse existing order if it's pending + if pending_signup.order.status == 'pending': + order = pending_signup.order + # Update contact info if needed + contact_name = signup_info.get('name') or signup_info.get('participant_name') or user.nickname or 'Activity User' + contact_phone = signup_info.get('phone') or user.phone_number or '' + if contact_name: order.customer_name = contact_name + if contact_phone: order.phone_number = contact_phone + + # Ensure activity is linked + if not order.activity: + order.activity = activity + + order.save() + + if not order: + # Check independent pending order + pending_order = Order.objects.filter( + wechat_user=user, + activity=activity, + status='pending' + ).first() + + if pending_order: + order = pending_order + # Ensure shipping address is up-to-date + order.shipping_address = activity.location or '线下活动' + order.save() + else: + contact_name = signup_info.get('name') or signup_info.get('participant_name') or user.nickname or 'Activity User' + contact_phone = signup_info.get('phone') or user.phone_number or '' + + order = Order.objects.create( + wechat_user=user, + activity=activity, + total_price=activity.price, + status='pending', + quantity=1, + customer_name=contact_name, + phone_number=contact_phone, + shipping_address=activity.location or '线下活动', + ) + + # Generate Pay Code + out_trade_no = f"ACT{activity.id}U{user.id}T{int(time.time())}" + order.out_trade_no = out_trade_no + order.save() + + wxpay, error_msg = get_wechat_pay_client(pay_type=WeChatPayType.NATIVE) + if not wxpay: + return Response({'error': f'支付配置错误: {error_msg}'}, status=500) + + code, message = wxpay.pay( + description=f"报名活动: {activity.title}", + out_trade_no=out_trade_no, + amount={ + 'total': int(activity.price * 100), + 'currency': 'CNY' + }, + notify_url=wxpay._notify_url, + attach=f'{{"type":"activity","activity_id":{activity.id}}}' + ) + + import json + result = json.loads(message) + if code in range(200, 300): + code_url = result.get('code_url') + + if pending_signup: + pending_signup.signup_info = signup_info + pending_signup.order = order + pending_signup.status = 'unpaid' # Explicitly set to unpaid + pending_signup.save() + else: + ActivitySignup.objects.create( + activity=activity, + user=user, + signup_info=signup_info, + status='unpaid', + order=order + ) + + return Response({ + 'payment_required': True, + 'code_url': code_url, + 'order_id': order.id, + 'price': activity.price, + 'message': '请完成支付' + }, status=200) + else: + return Response({'error': '支付接口调用失败', 'detail': result}, status=500) + + # Free Activity Signup + # Check auto_confirm + status_val = 'confirmed' if activity.auto_confirm else 'pending' + + signup = ActivitySignup.objects.create( + activity=activity, + user=user, + signup_info=signup_info, + status=status_val + ) + + # Send SMS for free activity signup (if confirmed) + if status_val == 'confirmed': + try: + from shop.sms_utils import notify_user_activity_signup_success + + # Mock an order object for the SMS template + # The template expects: customer_name, wechat_user, phone_number + class MockOrder: + def __init__(self, user, signup_info): + # Ensure we get the name and phone from signup_info first + # signup_info keys might vary, let's try common ones + self.customer_name = signup_info.get('name') or signup_info.get('username') or user.nickname or "用户" + self.wechat_user = user + self.phone_number = signup_info.get('phone') or signup_info.get('mobile') or user.phone_number or "" + + mock_order = MockOrder(user, signup_info) + + # Check if we have a valid phone number before sending + if mock_order.phone_number: + notify_user_activity_signup_success(mock_order, signup) + else: + print(f"Skipping SMS for signup {signup.id}: No phone number found") + except Exception as e: + print(f"发送免费活动报名短信失败: {str(e)}") + + serializer = ActivitySignupSerializer(signup) + return Response(serializer.data, status=201) + + @extend_schema(summary="我的报名记录") + @action(detail=False, methods=['get']) + def my_signups(self, request): + user = get_current_wechat_user(request) + if not user: + return Response({'error': '请先登录'}, status=401) + signups = ActivitySignup.objects.filter(user=user).order_by('-signup_time') + + # Sync payment status + for signup in signups: + signup.check_payment_status() + + serializer = ActivitySignupSerializer(signups, many=True) + return Response(serializer.data) + +class TopicViewSet(viewsets.ModelViewSet): + """ + 技术论坛帖子接口 + """ + queryset = Topic.objects.all() + serializer_class = TopicSerializer + permission_classes = [IsAuthorOrReadOnly] + filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend] + search_fields = ['title', 'content'] + filterset_fields = ['category', 'is_pinned'] + ordering_fields = ['created_at', 'view_count', 'order'] + ordering = ['-is_pinned', 'order', '-created_at'] + + def get_queryset(self): + qs = super().get_queryset() + # 列表接口仅显示已发布的帖子 + if self.action == 'list': + qs = qs.filter(status='published') + return qs + + def perform_create(self, serializer): + user = get_current_wechat_user(self.request) + # Auth check is done in create or permission, but here we need user for save + if user: + # 如果关联了系统用户(user字段不为空),则是管理员/内部人员,直接发布 + # 否则进入审核流程 + status = 'published' if user.user else 'pending' + serializer.save(author=user, status=status) + + def create(self, request, *args, **kwargs): + user = get_current_wechat_user(request) + if not user: + return Response({'error': '请先登录'}, status=401) + return super().create(request, *args, **kwargs) + + @action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny]) + def like(self, request, pk=None): + obj = self.get_object() + user = get_current_wechat_user(request) + if not user: + return Response({'error': '请先登录'}, status=401) + + if obj.likes.filter(id=user.id).exists(): + obj.likes.remove(user) + liked = False + else: + obj.likes.add(user) + liked = True + + return Response({'liked': liked, 'count': obj.likes.count()}) + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + instance.view_count += 1 + instance.save(update_fields=['view_count']) + serializer = self.get_serializer(instance) + return Response(serializer.data) + +class ReplyViewSet(viewsets.ModelViewSet): + """ + 帖子回复接口 + """ + queryset = Reply.objects.all() + serializer_class = ReplySerializer + permission_classes = [IsAuthorOrReadOnly] + + def perform_create(self, serializer): + user = get_current_wechat_user(self.request) + if user: + serializer.save(author=user) + + def create(self, request, *args, **kwargs): + user = get_current_wechat_user(request) + if not user: + return Response({'error': '请先登录'}, status=401) + return super().create(request, *args, **kwargs) + + @action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny]) + def like(self, request, pk=None): + obj = self.get_object() + user = get_current_wechat_user(request) + if not user: + return Response({'error': '请先登录'}, status=401) + + if obj.likes.filter(id=user.id).exists(): + obj.likes.remove(user) + liked = False + else: + obj.likes.add(user) + liked = True + + return Response({'liked': liked, 'count': obj.likes.count()}) + +import requests + +class TopicMediaViewSet(viewsets.ViewSet): + """ + 论坛多媒体资源上传接口 (代理到外部OSS服务) + """ + permission_classes = [] # 内部鉴权 + parser_classes = [parsers.MultiPartParser, parsers.FormParser] + + @extend_schema(summary="上传媒体文件 (返回URL用于Markdown)") + def create(self, request, *args, **kwargs): + user = get_current_wechat_user(request) + if not user: + return Response({'error': '请先登录'}, status=401) + + file_obj = request.FILES.get('file') + if not file_obj: + return Response({'error': '未提供文件'}, status=400) + + # 转发到外部 OSS 上传服务 + upload_url = "https://data.tangledup-ai.com/upload?folder=uploads%2Fmarket%2Fforum_image" + files = {'file': (file_obj.name, file_obj, file_obj.content_type)} + + try: + # 这里的 headers 不需要 Content-Type,requests 会自动设置 multipart/form-data + response = requests.post(upload_url, files=files, timeout=30) + + if response.status_code == 200: + data = response.json() + if data.get('success'): + # Create TopicMedia record + media_type = 'image' if 'image' in file_obj.content_type else 'video' + media_obj = TopicMedia.objects.create( + file_url=data.get('file_url'), + media_type=media_type, + # topic will be associated later + ) + + # 返回符合前端预期的格式 + return Response({ + 'id': media_obj.id, # Return real DB ID + 'file': media_obj.file_url, + 'media_type': media_obj.media_type, + 'created_at': media_obj.created_at + }) + else: + return Response({'error': '外部服务上传失败', 'detail': data}, status=400) + else: + return Response({'error': f'上传服务响应错误: {response.status_code}', 'detail': response.text}, status=502) + + except Exception as e: + return Response({'error': str(e)}, status=500) + +class AnnouncementViewSet(viewsets.ReadOnlyModelViewSet): + """ + 社区公告接口 + """ + queryset = Announcement.objects.all() + serializer_class = AnnouncementSerializer + permission_classes = [permissions.AllowAny] + + def get_queryset(self): + now = timezone.now() + qs = Announcement.objects.filter(is_active=True) + # Filter by start_time (if set, must be <= now) + qs = qs.filter(models.Q(start_time__isnull=True) | models.Q(start_time__lte=now)) + # Filter by end_time (if set, must be >= now) + qs = qs.filter(models.Q(end_time__isnull=True) | models.Q(end_time__gte=now)) + return qs.order_by('-is_pinned', '-priority', '-created_at') + +class AdminPublishViewSet(viewsets.ViewSet): + """ + 管理员/API发布接口 + """ + permission_classes = [] + authentication_classes = [] + + def check_api_key(self, request): + key = request.headers.get('X-API-KEY') or request.query_params.get('apikey') + if key != '123quant-speed': + return False + return True + + def get_admin_user_by_phone(self, phone): + if not phone: + return None + # Find WeChatUser by phone + user = WeChatUser.objects.filter(phone_number=phone).first() + if not user: + return None + + # Check if linked to a system user and has admin privileges (is_staff) + if user.user and user.user.is_staff: + return user + + return None + + @extend_schema( + summary="API发布活动", + request=AdminActivitySerializer, + parameters=[ + OpenApiParameter( + name='apikey', + description='API访问密钥', + required=True, + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY + ), + OpenApiParameter( + name='phone_number', + description='管理员手机号 (用于关联发布者)', + required=True, + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY + ) + ] + ) + @action(detail=False, methods=['post']) + def publish_activity(self, request): + if not self.check_api_key(request): + return Response({'error': 'Invalid API Key'}, status=403) + + phone = request.data.get('phone_number') or request.query_params.get('phone_number') + user = self.get_admin_user_by_phone(phone) + if not user: + return Response({'error': 'Admin user not found with this phone number'}, status=404) + + data = request.data.copy() + serializer = AdminActivitySerializer(data=data) + if serializer.is_valid(): + activity = serializer.save(author=user) + return Response(serializer.data, status=201) + return Response(serializer.errors, status=400) + + @extend_schema( + summary="API发布帖子", + request=AdminTopicSerializer, + parameters=[ + OpenApiParameter( + name='apikey', + description='API访问密钥', + required=True, + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY + ), + OpenApiParameter( + name='phone_number', + description='管理员手机号 (用于关联发布者)', + required=True, + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY + ) + ] + ) + @action(detail=False, methods=['post']) + def publish_topic(self, request): + if not self.check_api_key(request): + return Response({'error': 'Invalid API Key'}, status=403) + + phone = request.data.get('phone_number') or request.query_params.get('phone_number') + user = self.get_admin_user_by_phone(phone) + if not user: + return Response({'error': 'Admin user not found with this phone number'}, status=404) + + data = request.data.copy() + serializer = AdminTopicSerializer(data=data) + if serializer.is_valid(): + # Only set status to published if not provided, otherwise respect the input + status = data.get('status', 'published') + topic = serializer.save(author=user, status=status) + return Response(serializer.data, status=201) + return Response(serializer.errors, status=400) diff --git a/backend/competition/__init__.py b/backend/competition/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/competition/admin.py b/backend/competition/admin.py new file mode 100644 index 0000000..c34079a --- /dev/null +++ b/backend/competition/admin.py @@ -0,0 +1,198 @@ +from django.contrib import admin +from django.utils.html import format_html +from unfold.admin import ModelAdmin +from unfold.decorators import display +from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, HomePageConfig, CarouselItem + + +class CarouselItemInline(admin.TabularInline): + model = CarouselItem + extra = 1 + tab = True + fields = ('carousel_type', 'image', 'image_url', 'title', 'subtitle', 'status', 'status_color', 'date', 'location', 'order', 'is_active') + autocomplete_fields = [] + + +@admin.register(HomePageConfig) +class HomePageConfigAdmin(ModelAdmin): + list_display = ['id', 'main_title', 'carousel1_title', 'carousel2_title', 'organizer', 'undertaker', 'is_active'] + list_editable = ['main_title', 'carousel1_title', 'carousel2_title', 'organizer', 'undertaker', 'is_active'] + fieldsets = ( + ('首页Banner', { + 'fields': ('banner_image', 'banner_image_url'), + 'description': '首页顶部Banner图片,可以上传本地图片或填写URL' + }), + ('标题设置', { + 'fields': ('main_title', 'carousel1_title', 'carousel2_title') + }), + ('主办单位', { + 'fields': ('organizer', 'undertaker') + }), + ('状态', { + 'fields': ('is_active',) + }), + ) + + +@admin.register(CarouselItem) +class CarouselItemAdmin(ModelAdmin): + list_display = ['title', 'carousel_type', 'status', 'location', 'order', 'is_active', 'created_at'] + list_filter = ['carousel_type', 'status', 'is_active'] + search_fields = ['title', 'subtitle', 'location'] + readonly_fields = ['image_preview'] + fieldsets = ( + ('轮播图类型', { + 'fields': ('carousel_type',) + }), + ('图片设置', { + 'fields': ('image', 'image_preview', 'image_url'), + 'description': '优先使用本地上传的图片,上传后可预览' + }), + ('内容设置', { + 'fields': ('title', 'subtitle', 'status', 'status_color', 'date', 'location') + }), + ('显示设置', { + 'fields': ('order', 'is_active') + }), + ) + + @display(description='图片预览') + def image_preview(self, obj): + if obj.image: + return format_html('', obj.image.url) + elif obj.image_url: + return format_html('', obj.image_url) + return "暂无图片" + + +class ScoreDimensionInline(admin.TabularInline): + model = ScoreDimension + extra = 1 + tab = True + fields = ('name', 'description', 'weight', 'max_score', 'is_public', 'is_peer_review', 'order') + +class ProjectFileInline(admin.TabularInline): + model = ProjectFile + extra = 0 + tab = True + +@admin.register(Competition) +class CompetitionAdmin(ModelAdmin): + list_display = ['title', 'status', 'allow_contestant_grading', 'start_time', 'end_time', 'is_active', 'created_at'] + list_filter = ['status', 'allow_contestant_grading', 'is_active'] + search_fields = ['title', 'description'] + inlines = [ScoreDimensionInline] + + fieldsets = ( + ('基本信息', { + 'fields': ('title', 'description', 'rule_description', 'condition_description') + }), + ('封面设置', { + 'fields': ('cover_image', 'cover_image_url'), + 'description': '封面图可以上传本地图片,也可以填写外部链接,优先显示本地上传的图片' + }), + ('时间和状态', { + 'fields': ('start_time', 'end_time', 'status', 'project_visibility', 'allow_contestant_grading', 'is_active') + }), + ) + + actions = ['make_published', 'make_ended'] + + def make_published(self, request, queryset): + queryset.update(status='published') + make_published.short_description = "发布选中比赛" + + def make_ended(self, request, queryset): + queryset.update(status='ended') + make_ended.short_description = "结束选中比赛" + +@admin.register(CompetitionEnrollment) +class CompetitionEnrollmentAdmin(ModelAdmin): + list_display = ['competition', 'user_info_display', 'role', 'status', 'created_at'] + list_filter = ['competition', 'role', 'status'] + search_fields = ['user__nickname', 'user__phone_number', 'competition__title'] + autocomplete_fields = ['user', 'competition'] + actions = ['approve_enrollment', 'reject_enrollment'] + + @display(description="报名用户 (手机号/昵称)") + def user_info_display(self, obj): + if not obj.user: + return "-" + phone = obj.user.phone_number or "无手机号" + nickname = obj.user.nickname or "无昵称" + return f"{phone} ({nickname})" + + def approve_enrollment(self, request, queryset): + queryset.update(status='approved') + approve_enrollment.short_description = "通过审核" + + def reject_enrollment(self, request, queryset): + queryset.update(status='rejected') + reject_enrollment.short_description = "拒绝申请" + +@admin.register(Project) +class ProjectAdmin(ModelAdmin): + list_display = ['id', 'title', 'competition', 'contestant_info_display', 'status', 'final_score', 'created_at'] + list_filter = ['competition', 'status'] + search_fields = ['id', 'title', 'contestant__user__nickname', 'contestant__user__phone_number'] + autocomplete_fields = ['competition', 'contestant'] + inlines = [ProjectFileInline] + readonly_fields = ['id', 'final_score'] + + fieldsets = ( + ('基本信息', { + 'fields': ('competition', 'contestant', 'title', 'description', 'team_info') + }), + ('封面设置', { + 'fields': ('cover_image', 'cover_image_url'), + 'description': '封面图可以上传本地图片,也可以填写外部链接,优先显示本地上传的图片' + }), + ('状态和得分', { + 'fields': ('status', 'final_score') + }), + ) + + @display(description="参赛人员 (手机号/昵称)") + def contestant_info_display(self, obj): + if not obj.contestant or not obj.contestant.user: + return "-" + user = obj.contestant.user + phone = user.phone_number or "无手机号" + nickname = user.nickname or "无昵称" + return f"{phone} ({nickname})" + +@admin.register(Score) +class ScoreAdmin(ModelAdmin): + list_display = ['project', 'judge_info_display', 'dimension', 'score', 'created_at'] + list_filter = ['project__competition', 'dimension'] + search_fields = ['project__title', 'judge__user__nickname', 'judge__user__phone_number'] + autocomplete_fields = ['project', 'judge'] + + @display(description="评委 (手机号/昵称)") + def judge_info_display(self, obj): + if not obj.judge or not obj.judge.user: + return "-" + user = obj.judge.user + phone = user.phone_number or "无手机号" + nickname = user.nickname or "无昵称" + return f"{phone} ({nickname})" + +@admin.register(Comment) +class CommentAdmin(ModelAdmin): + list_display = ['project', 'judge_info_display', 'content_preview', 'created_at'] + list_filter = ['project__competition'] + search_fields = ['project__title', 'judge__user__nickname', 'judge__user__phone_number', 'content'] + autocomplete_fields = ['project', 'judge'] + + @display(description="评委 (手机号/昵称)") + def judge_info_display(self, obj): + if not obj.judge or not obj.judge.user: + return "-" + user = obj.judge.user + phone = user.phone_number or "无手机号" + nickname = user.nickname or "无昵称" + return f"{phone} ({nickname})" + + def content_preview(self, obj): + return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content + content_preview.short_description = "评语内容" diff --git a/backend/competition/apps.py b/backend/competition/apps.py new file mode 100644 index 0000000..111dbee --- /dev/null +++ b/backend/competition/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CompetitionConfig(AppConfig): + name = 'competition' + verbose_name = '首页管理' diff --git a/backend/competition/judge_urls.py b/backend/competition/judge_urls.py new file mode 100644 index 0000000..efe8719 --- /dev/null +++ b/backend/competition/judge_urls.py @@ -0,0 +1,21 @@ +from django.urls import path +from django.views.generic import RedirectView +from . import judge_views + +urlpatterns = [ + # 默认跳转到登录页 + path('', RedirectView.as_view(url='login/', permanent=False), name='judge_index'), + path('login/', judge_views.login_view, name='judge_login'), + path('logout/', judge_views.logout_view, name='judge_logout'), + path('send_code/', judge_views.send_code, name='judge_send_code'), + path('dashboard/', judge_views.dashboard, name='judge_dashboard'), + path('upload/', judge_views.upload_audio, name='judge_upload'), + path('ai/manage/', judge_views.ai_manage, name='judge_ai_manage'), + + # API + path('api/projects//', judge_views.project_detail_api, name='judge_project_detail_api'), + path('api/score/submit/', judge_views.submit_score, name='judge_submit_score'), + path('api/upload/', judge_views.upload_audio, name='judge_api_upload'), + path('api/upload/url/', judge_views.upload_audio_url, name='judge_api_upload_url'), + path('api/ai//delete/', judge_views.delete_ai_task, name='judge_delete_ai_task'), +] diff --git a/backend/competition/judge_views.py b/backend/competition/judge_views.py new file mode 100644 index 0000000..6bf03f6 --- /dev/null +++ b/backend/competition/judge_views.py @@ -0,0 +1,659 @@ +import json +import logging +import random +import time +import requests +import threading +from django.shortcuts import render, redirect, get_object_or_404 +from django.http import JsonResponse, HttpResponse +from django.views.decorators.http import require_http_methods +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie +from django.core.cache import cache +from django.contrib.auth.models import User +from django.conf import settings +from django.db.models import Q, Avg +from django.utils import timezone +import uuid + +from .models import Competition, CompetitionEnrollment, Project, Score, ScoreDimension, Comment, ProjectFile +from shop.models import WeChatUser +from shop.sms_utils import send_sms +from ai_services.models import TranscriptionTask +from ai_services.services import AliyunTingwuService + +logger = logging.getLogger(__name__) + +# --- Helper Functions --- + +def get_client_ip(request): + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + +def log_audit(request, action, target, result="SUCCESS", details=""): + judge_id = request.session.get('judge_id') + phone = request.session.get('judge_phone', 'Unknown') + role = request.session.get('judge_role', 'unknown') + ip = get_client_ip(request) + timestamp = timezone.now().strftime("%Y-%m-%d %H:%M:%S") + + log_entry = f"[{timestamp}] IP:{ip} | Phone:{phone} | Role:{role} | Action:{action} | Target:{target} | Result:{result} | Details:{details}\n" + + # Write to a file + try: + with open(settings.BASE_DIR / 'judge_audit.log', 'a', encoding='utf-8') as f: + f.write(log_entry) + except Exception as e: + logger.error(f"Failed to write audit log: {e}") + +def judge_required(view_func): + def wrapper(request, *args, **kwargs): + if not request.session.get('judge_id'): + return redirect('judge_login') + return view_func(request, *args, **kwargs) + return wrapper + +def check_contestant_access(view_func): + """ + Check if the user is allowed to access. + Contestants have limited access. + """ + def wrapper(request, *args, **kwargs): + if not request.session.get('judge_id'): + return redirect('judge_login') + + role = request.session.get('judge_role') + if role == 'contestant': + # Some views might be restricted for contestants + # For now, this decorator just ensures login, but specific views handle logic + pass + + return view_func(request, *args, **kwargs) + return wrapper + +# --- Views --- + +def admin_entry(request): + """Entry point for /competition/admin""" + if request.session.get('judge_id'): + return redirect('judge_dashboard') + return redirect('judge_login') + +@csrf_exempt +def login_view(request): + if request.method == 'GET': + return render(request, 'judge/login.html') + + phone = request.POST.get('phone') + code = request.POST.get('code') + + if not phone or not code: + return render(request, 'judge/login.html', {'error': '请输入手机号和验证码'}) + + # Verify Code + cached_code = cache.get(f"sms_code_{phone}") + # Universal pass code for development/testing + if code != cached_code and code != '888888': + return render(request, 'judge/login.html', {'error': '验证码错误 or expired'}) + + # Check User + try: + user = WeChatUser.objects.filter(phone_number=phone).first() + if not user: + return render(request, 'judge/login.html', {'error': '该手机号未绑定用户'}) + + # Check roles + # Priority: Judge > Guest > Contestant (if allowed) + is_judge = CompetitionEnrollment.objects.filter(user=user, role='judge').exists() + is_guest = CompetitionEnrollment.objects.filter(user=user, role='guest').exists() + + role = None + if is_judge: + role = 'judge' + elif is_guest: + role = 'guest' + else: + # Check if contestant in any competition with allow_contestant_grading=True + contestant_enrollments = CompetitionEnrollment.objects.filter( + user=user, + role='contestant', + competition__allow_contestant_grading=True + ) + if contestant_enrollments.exists(): + role = 'contestant' + + if not role: + return render(request, 'judge/login.html', {'error': '您没有权限登录系统'}) + + # Login Success + request.session['judge_id'] = user.id + request.session['judge_phone'] = phone + request.session['judge_name'] = user.nickname + request.session['judge_role'] = role + + log_audit(request, 'LOGIN', 'System', 'SUCCESS', f"User {user.nickname} logged in as {role}") + + return redirect('judge_dashboard') + + except Exception as e: + logger.error(f"Login error: {e}") + return render(request, 'judge/login.html', {'error': '系统错误'}) + +@csrf_exempt +def send_code(request): + if request.method != 'POST': + return JsonResponse({'success': False, 'message': 'Method not allowed'}) + + try: + data = json.loads(request.body) + phone = data.get('phone') + + if not phone or len(phone) != 11: + return JsonResponse({'success': False, 'message': 'Invalid phone number'}) + + # Generate Code + code = str(random.randint(100000, 999999)) # 6 digits to match typical SMS + cache.set(f"sms_code_{phone}", code, timeout=300) # 5 mins + + # Send SMS using the specified API + def _send_async(): + try: + api_url = "https://data.tangledup-ai.com/api/send-sms" + payload = { + "phone_number": phone, + "code": code, + "template_code": "SMS_493295002", + "sign_name": "叠加态科技云南", + "additionalProp1": {} + } + headers = { + "Content-Type": "application/json", + "accept": "application/json" + } + response = requests.post(api_url, json=payload, headers=headers, timeout=15) + logger.info(f"SMS Response for {phone}: {response.status_code} - {response.text}") + except Exception as e: + logger.error(f"发送短信异常: {str(e)}") + + threading.Thread(target=_send_async).start() + + return JsonResponse({'success': True}) + except Exception as e: + return JsonResponse({'success': False, 'message': str(e)}) + +def logout_view(request): + log_audit(request, 'LOGOUT', 'System') + request.session.flush() + return redirect('judge_login') + +@judge_required +def dashboard(request): + judge_id = request.session['judge_id'] + role = request.session.get('judge_role', 'judge') + user = WeChatUser.objects.get(id=judge_id) + + # Get competitions + if role == 'judge': + enrollments = CompetitionEnrollment.objects.filter(user=user, role='judge') + elif role == 'guest': + enrollments = CompetitionEnrollment.objects.filter(user=user, role='guest') + else: + # Contestant: only competitions allowing grading + enrollments = CompetitionEnrollment.objects.filter( + user=user, + role='contestant', + competition__allow_contestant_grading=True + ) + + competition_ids = enrollments.values_list('competition_id', flat=True) + + # Get Projects + projects = Project.objects.filter( + competition_id__in=competition_ids, + status='submitted' + ).select_related('contestant__user') + + # Format for template + project_list = [] + for p in projects: + # Check current score/grading status for this user + # Note: Score model links to 'judge' which is a CompetitionEnrollment + # We need the enrollment for this user in this competition + user_enrollment = enrollments.filter(competition=p.competition).first() + + project_list.append({ + 'id': p.id, + 'title': p.title, + 'cover_image_url': p.cover_image_url or (p.cover_image.url if p.cover_image else ''), + 'contestant_name': p.contestant.user.nickname, + 'current_score': p.final_score, # Global score + 'status_class': 'status-submitted', + 'get_status_display': p.get_status_display() + }) + + return render(request, 'judge/dashboard.html', { + 'projects': project_list, + 'user_role': role, + 'user_name': request.session.get('judge_name', '用户') + }) + +@judge_required +def project_detail_api(request, project_id): + judge_id = request.session['judge_id'] + role = request.session.get('judge_role', 'judge') + user = WeChatUser.objects.get(id=judge_id) + project = get_object_or_404(Project, id=project_id) + + # Check permission + # User must be enrolled in the project's competition with correct role/settings + if role == 'judge': + enrollment = CompetitionEnrollment.objects.filter(user=user, role='judge', competition=project.competition).first() + elif role == 'guest': + enrollment = CompetitionEnrollment.objects.filter(user=user, role='guest', competition=project.competition).first() + else: + enrollment = CompetitionEnrollment.objects.filter( + user=user, + role='contestant', + competition=project.competition, + competition__allow_contestant_grading=True + ).first() + + if not enrollment: + return JsonResponse({'error': 'No permission'}, status=403) + + # Get Dimensions - 根据角色过滤 + if role == 'contestant': + dimensions = ScoreDimension.objects.filter( + competition=project.competition, + is_public=True, + is_peer_review=True + ).order_by('order') + else: + dimensions = ScoreDimension.objects.filter( + competition=project.competition, + is_public=True, + is_peer_review=False + ).order_by('order') + + # Get existing scores by THIS user + scores = Score.objects.filter(project=project, judge=enrollment) + score_map = {s.dimension_id: s.score for s in scores} + + dim_data = [] + for d in dimensions: + dim_data.append({ + 'id': d.id, + 'name': d.name, + 'weight': float(d.weight), + 'max_score': d.max_score, + 'current_score': float(score_map.get(d.id, 0)) + }) + + # Get Comments + # If role is contestant, they CANNOT see other people's comments + history = [] + current_comment = "" + + if role in ['judge', 'guest']: + comments = Comment.objects.filter(project=project).order_by('-created_at') + for c in comments: + history.append({ + 'judge_name': c.judge.user.nickname, + 'content': c.content, + 'created_at': c.created_at.strftime("%Y-%m-%d %H:%M") + }) + if c.judge.id == enrollment.id: + current_comment = c.content + else: + # Contestant: only see their own comment + my_comment = Comment.objects.filter(project=project, judge=enrollment).first() + if my_comment: + current_comment = my_comment.content + history.append({ + 'judge_name': user.nickname, # Self + 'content': my_comment.content, + 'created_at': my_comment.created_at.strftime("%Y-%m-%d %H:%M") + }) + + # Include AI results + latest_task = TranscriptionTask.objects.filter(project=project, status='SUCCEEDED').order_by('-created_at').first() + ai_data = None + if latest_task: + ai_data = { + 'transcription': latest_task.transcription, + 'summary': latest_task.summary, + 'auto_chapters_data': latest_task.auto_chapters_data, + 'transcription_data': latest_task.transcription_data + } + + latest_task_any = TranscriptionTask.objects.filter(project=project).order_by('-created_at').first() + audio_url = latest_task_any.file_url if latest_task_any else None + + data = { + 'id': project.id, + 'title': project.title, + 'description': project.description, + 'contestant_name': project.contestant.user.nickname, + 'dimensions': dim_data, + 'history_comments': history, + 'current_comment': current_comment, + 'ai_result': ai_data, + 'audio_url': audio_url, + 'can_grade': role == 'judge' or (role == 'contestant' and project.contestant.user != user) # Contestant can grade others if allowed + } + + # Specifically for guest: can_grade is False + if role == 'guest': + data['can_grade'] = False + + return JsonResponse(data) + +@judge_required +@csrf_exempt +def submit_score(request): + if request.method != 'POST': + return JsonResponse({'success': False, 'message': 'Method not allowed'}) + + try: + data = json.loads(request.body) + project_id = data.get('project_id') + comment_content = data.get('comment') + + judge_id = request.session['judge_id'] + role = request.session.get('judge_role', 'judge') + + if role == 'guest': + return JsonResponse({'success': False, 'message': '嘉宾无权评分'}) + + user = WeChatUser.objects.get(id=judge_id) + project = get_object_or_404(Project, id=project_id) + + enrollment = None + if role == 'judge': + enrollment = CompetitionEnrollment.objects.filter(user=user, role='judge', competition=project.competition).first() + else: + enrollment = CompetitionEnrollment.objects.filter( + user=user, + role='contestant', + competition=project.competition, + competition__allow_contestant_grading=True + ).first() + + if not enrollment: + return JsonResponse({'success': False, 'message': 'No permission'}) + + # Save Scores - 根据角色过滤维度 + if role == 'contestant': + dimensions = ScoreDimension.objects.filter( + competition=project.competition, + is_public=True, + is_peer_review=True + ) + else: + dimensions = ScoreDimension.objects.filter( + competition=project.competition, + is_public=True, + is_peer_review=False + ) + for d in dimensions: + score_key = f'score_{d.id}' + if score_key in data: + val = data[score_key] + Score.objects.update_or_create( + project=project, + judge=enrollment, + dimension=d, + defaults={'score': val} + ) + + # Save Comment + if comment_content: + Comment.objects.update_or_create( + project=project, + judge=enrollment, + defaults={'content': comment_content} + ) + + # Recalculate Project Score + project.calculate_score() + + log_audit(request, 'SCORE_UPDATE', f"Project {project.id}", 'SUCCESS') + + return JsonResponse({'success': True}) + + except Exception as e: + logger.error(f"Submit score error: {e}") + return JsonResponse({'success': False, 'message': str(e)}) + +@judge_required +@csrf_exempt +def upload_audio(request): + # Contestants cannot upload, but Guests can + role = request.session.get('judge_role') + if role not in ['judge', 'guest']: + return JsonResponse({'success': False, 'message': 'Permission denied'}) + + if request.method != 'POST': + return JsonResponse({'success': False, 'message': 'Method not allowed'}) + + judge_id = request.session['judge_id'] + file_obj = request.FILES.get('file') + project_id = request.POST.get('project_id') + + if not file_obj or not project_id: + return JsonResponse({'success': False, 'message': 'Missing file or project_id'}) + + try: + # Check permission + user = WeChatUser.objects.get(id=judge_id) + project = Project.objects.get(id=project_id) + + # Verify judge/guest has access to this project's competition + enrollment = CompetitionEnrollment.objects.filter( + user=user, + role=role, + competition=project.competition + ).first() + + if not enrollment: + return JsonResponse({'success': False, 'message': 'No permission for this project'}) + + # Upload to OSS & Create Task + service = AliyunTingwuService() + if not service.bucket: + return JsonResponse({'success': False, 'message': 'OSS not configured'}) + + file_extension = file_obj.name.split('.')[-1] + file_name = f"transcription/{uuid.uuid4()}.{file_extension}" + oss_url = service.upload_to_oss(file_obj, file_name) + + # Create Task Record + task = TranscriptionTask.objects.create( + project=project, + file_url=oss_url, + status=TranscriptionTask.Status.PENDING + ) + + # Call Tingwu + try: + tingwu_response = service.create_transcription_task(oss_url) + # Handle response format + if 'Data' in tingwu_response and isinstance(tingwu_response['Data'], dict): + task_id = tingwu_response['Data'].get('TaskId') + else: + task_id = tingwu_response.get('TaskId') + + if task_id: + task.task_id = task_id + task.status = TranscriptionTask.Status.PROCESSING + task.save() + + log_audit(request, 'UPLOAD_AUDIO', f"Task {task.id}", 'SUCCESS') + return JsonResponse({'success': True, 'task_id': task.id, 'file_url': oss_url}) + else: + task.status = TranscriptionTask.Status.FAILED + task.error_message = "No TaskId returned" + task.save() + return JsonResponse({'success': False, 'message': 'Failed to create Tingwu task'}) + + except Exception as e: + task.status = TranscriptionTask.Status.FAILED + task.error_message = str(e) + task.save() + return JsonResponse({'success': False, 'message': f'Tingwu Error: {e}'}) + + except Exception as e: + logger.error(f"Upload error: {e}") + return JsonResponse({'success': False, 'message': str(e)}) + +@judge_required +@csrf_exempt +def upload_audio_url(request): + """ + 处理 URL 上传音频的 API + 通过给定的音频 URL 直接进行处理,无需上传文件 + """ + role = request.session.get('judge_role') + if role not in ['judge', 'guest']: + return JsonResponse({'success': False, 'message': 'Permission denied'}) + + if request.method != 'POST': + return JsonResponse({'success': False, 'message': 'Method not allowed'}) + + import json + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({'success': False, 'message': 'Invalid JSON'}) + + audio_url = data.get('url') + project_id = data.get('project_id') + + if not audio_url or not project_id: + return JsonResponse({'success': False, 'message': 'Missing url or project_id'}) + + # 验证 URL 格式 + if not audio_url.startswith(('http://', 'https://')): + return JsonResponse({'success': False, 'message': 'Invalid URL format'}) + + judge_id = request.session['judge_id'] + + try: + # 验证权限 + user = WeChatUser.objects.get(id=judge_id) + project = Project.objects.get(id=project_id) + + enrollment = CompetitionEnrollment.objects.filter( + user=user, + role=role, + competition=project.competition + ).first() + + if not enrollment: + return JsonResponse({'success': False, 'message': 'No permission for this project'}) + + # 创建任务记录,使用 URL 作为 file_url + service = AliyunTingwuService() + + task = TranscriptionTask.objects.create( + project=project, + file_url=audio_url, + status=TranscriptionTask.Status.PENDING + ) + + # 调用 Tingwu 服务 + try: + tingwu_response = service.create_transcription_task(audio_url) + + if 'Data' in tingwu_response and isinstance(tingwu_response['Data'], dict): + task_id = tingwu_response['Data'].get('TaskId') + else: + task_id = tingwu_response.get('TaskId') + + if task_id: + task.task_id = task_id + task.status = TranscriptionTask.Status.PROCESSING + task.save() + + log_audit(request, 'UPLOAD_AUDIO_URL', f"Task {task.id}", 'SUCCESS') + return JsonResponse({'success': True, 'task_id': task.id, 'file_url': audio_url}) + else: + task.status = TranscriptionTask.Status.FAILED + task.error_message = "No TaskId returned" + task.save() + return JsonResponse({'success': False, 'message': 'Failed to create Tingwu task'}) + + except Exception as e: + task.status = TranscriptionTask.Status.FAILED + task.error_message = str(e) + task.save() + return JsonResponse({'success': False, 'message': f'Tingwu Error: {e}'}) + + except Exception as e: + logger.error(f"Upload URL error: {e}") + return JsonResponse({'success': False, 'message': str(e)}) + +@judge_required +def ai_manage(request): + # Contestants cannot access AI manage + role = request.session.get('judge_role') + if role not in ['judge', 'guest']: + return redirect('judge_dashboard') + + judge_id = request.session['judge_id'] + user = WeChatUser.objects.get(id=judge_id) + enrollments = CompetitionEnrollment.objects.filter(user=user, role=role) + competition_ids = enrollments.values_list('competition_id', flat=True) + + # Get tasks for projects in these competitions + tasks = TranscriptionTask.objects.filter( + project__competition_id__in=competition_ids + ).select_related('project').order_by('-created_at') + + task_list = [] + for t in tasks: + # Get Evaluation Score + # AIEvaluation is linked to Task + evals = t.ai_evaluations.all() + score = evals[0].score if evals else None + + task_list.append({ + 'id': t.id, + 'project': t.project, + 'file_url': t.file_url, + 'file_name': t.file_url.split('/')[-1] if t.file_url else 'Unknown', + 'status': t.status, + 'status_class': 'status-' + t.status.lower(), # CSS class + 'get_status_display': t.get_status_display(), + 'ai_score': score + }) + + return render(request, 'judge/ai_manage.html', { + 'tasks': task_list, + 'user_name': request.session.get('judge_name', '用户'), + 'user_role': role + }) + +@judge_required +@csrf_exempt +def delete_ai_task(request, task_id): + role = request.session.get('judge_role') + if role not in ['judge', 'guest']: + return JsonResponse({'success': False, 'message': 'Permission denied'}) + + if request.method != 'POST': + return JsonResponse({'success': False, 'message': 'Method not allowed'}) + + try: + task = get_object_or_404(TranscriptionTask, id=task_id) + # Permission check + # ... + + task.delete() + log_audit(request, 'DELETE_TASK', f"Task {task_id}", 'SUCCESS') + return JsonResponse({'success': True}) + except Exception as e: + return JsonResponse({'success': False, 'message': str(e)}) diff --git a/backend/competition/migrations/0001_initial.py b/backend/competition/migrations/0001_initial.py new file mode 100644 index 0000000..f13ce84 --- /dev/null +++ b/backend/competition/migrations/0001_initial.py @@ -0,0 +1,141 @@ +# Generated by Django 6.0.1 on 2026-03-10 02:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('shop', '0039_vccourse_video_embed_code'), + ] + + operations = [ + migrations.CreateModel( + name='Competition', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='比赛名称')), + ('description', models.TextField(verbose_name='比赛简介')), + ('rule_description', models.TextField(verbose_name='规则说明')), + ('condition_description', models.TextField(blank=True, verbose_name='参赛条件说明')), + ('cover_image', models.ImageField(blank=True, null=True, upload_to='competitions/covers/', verbose_name='封面图')), + ('start_time', models.DateTimeField(verbose_name='开始时间')), + ('end_time', models.DateTimeField(verbose_name='结束时间')), + ('status', models.CharField(choices=[('draft', '草稿'), ('published', '已发布'), ('registration', '报名中'), ('submission', '作品提交中'), ('judging', '评审中'), ('ended', '已结束')], default='draft', max_length=20, verbose_name='状态')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '比赛', + 'verbose_name_plural': '比赛管理', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='CompetitionEnrollment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('role', models.CharField(choices=[('contestant', '选手'), ('judge', '评委'), ('guest', '嘉宾')], default='contestant', max_length=20, verbose_name='角色')), + ('status', models.CharField(choices=[('pending', '待审核'), ('approved', '已通过'), ('rejected', '已拒绝')], default='pending', max_length=20, verbose_name='状态')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='competition.competition', verbose_name='所属比赛')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competitions', to='shop.wechatuser', verbose_name='用户')), + ], + options={ + 'verbose_name': '比赛人员', + 'verbose_name_plural': '人员管理', + 'unique_together': {('competition', 'user')}, + }, + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=200, verbose_name='项目名称')), + ('description', models.TextField(verbose_name='项目介绍')), + ('team_info', models.TextField(blank=True, verbose_name='团队介绍')), + ('cover_image', models.ImageField(blank=True, null=True, upload_to='competitions/projects/covers/', verbose_name='项目封面')), + ('status', models.CharField(choices=[('draft', '草稿'), ('submitted', '已提交')], default='draft', max_length=20, verbose_name='状态')), + ('final_score', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='最终得分')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='competition.competition', verbose_name='所属比赛')), + ('contestant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='competition.competitionenrollment', verbose_name='参赛选手')), + ], + options={ + 'verbose_name': '参赛项目', + 'verbose_name_plural': '项目管理', + 'ordering': ['-final_score', '-created_at'], + }, + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField(verbose_name='评语内容')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='评论时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('judge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_comments', to='competition.competitionenrollment', verbose_name='评委')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='competition.project', verbose_name='所属项目')), + ], + options={ + 'verbose_name': '评委评语', + 'verbose_name_plural': '评语管理', + }, + ), + migrations.CreateModel( + name='ProjectFile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_type', models.CharField(choices=[('ppt', 'PPT演示文稿'), ('pdf', 'PDF文档'), ('image', '图片'), ('video', '视频'), ('doc', '文档'), ('other', '其他')], default='other', max_length=20, verbose_name='文件类型')), + ('file', models.FileField(blank=True, null=True, upload_to='competitions/projects/files/', verbose_name='文件')), + ('file_url', models.URLField(blank=True, help_text='视频等大文件建议使用外部链接', null=True, verbose_name='文件链接')), + ('name', models.CharField(blank=True, max_length=100, verbose_name='文件名称')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='上传时间')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='competition.project', verbose_name='所属项目')), + ], + options={ + 'verbose_name': '项目附件', + 'verbose_name_plural': '附件管理', + }, + ), + migrations.CreateModel( + name='ScoreDimension', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='维度名称')), + ('description', models.TextField(blank=True, verbose_name='维度说明')), + ('weight', models.DecimalField(decimal_places=2, default=1.0, help_text='例如 0.3 表示 30%', max_digits=5, verbose_name='权重')), + ('max_score', models.IntegerField(default=100, verbose_name='满分值')), + ('order', models.IntegerField(default=0, verbose_name='排序权重')), + ('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_dimensions', to='competition.competition', verbose_name='所属比赛')), + ], + options={ + 'verbose_name': '评分维度', + 'verbose_name_plural': '评分维度配置', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='Score', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score', models.DecimalField(decimal_places=1, max_digits=5, verbose_name='得分')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='打分时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('judge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_scores', to='competition.competitionenrollment', verbose_name='评委')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scores', to='competition.project', verbose_name='所属项目')), + ('dimension', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.scoredimension', verbose_name='评分维度')), + ], + options={ + 'verbose_name': '评分记录', + 'verbose_name_plural': '评分记录', + 'unique_together': {('project', 'judge', 'dimension')}, + }, + ), + ] diff --git a/backend/competition/migrations/0002_competition_cover_image_url_project_cover_image_url.py b/backend/competition/migrations/0002_competition_cover_image_url_project_cover_image_url.py new file mode 100644 index 0000000..ae565c5 --- /dev/null +++ b/backend/competition/migrations/0002_competition_cover_image_url_project_cover_image_url.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-03-10 02:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='cover_image_url', + field=models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='封面图URL'), + ), + migrations.AddField( + model_name='project', + name='cover_image_url', + field=models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='项目封面URL'), + ), + ] diff --git a/backend/competition/migrations/0003_competition_project_visibility.py b/backend/competition/migrations/0003_competition_project_visibility.py new file mode 100644 index 0000000..519c512 --- /dev/null +++ b/backend/competition/migrations/0003_competition_project_visibility.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-03-10 06:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0002_competition_cover_image_url_project_cover_image_url'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='project_visibility', + field=models.CharField(choices=[('public', '公开可见'), ('contestant', '选手及以上可见'), ('guest', '嘉宾及评委可见'), ('judge', '仅评委可见')], default='public', max_length=20, verbose_name='项目可见性'), + ), + ] diff --git a/backend/competition/migrations/0004_competition_allow_contestant_grading.py b/backend/competition/migrations/0004_competition_allow_contestant_grading.py new file mode 100644 index 0000000..d1d4981 --- /dev/null +++ b/backend/competition/migrations/0004_competition_allow_contestant_grading.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-03-12 05:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0003_competition_project_visibility'), + ] + + operations = [ + migrations.AddField( + model_name='competition', + name='allow_contestant_grading', + field=models.BooleanField(default=False, verbose_name='允许选手互评'), + ), + ] diff --git a/backend/competition/migrations/0005_scoredimension_is_public.py b/backend/competition/migrations/0005_scoredimension_is_public.py new file mode 100644 index 0000000..278f478 --- /dev/null +++ b/backend/competition/migrations/0005_scoredimension_is_public.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-03-12 05:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0004_competition_allow_contestant_grading'), + ] + + operations = [ + migrations.AddField( + model_name='scoredimension', + name='is_public', + field=models.BooleanField(default=True, help_text='如果关闭,评委端将看不到此评分维度,通常用于AI自动评分', verbose_name='是否公开给评委'), + ), + ] diff --git a/backend/competition/migrations/0006_add_peer_review_field.py b/backend/competition/migrations/0006_add_peer_review_field.py new file mode 100644 index 0000000..325646b --- /dev/null +++ b/backend/competition/migrations/0006_add_peer_review_field.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-03-17 14:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0005_scoredimension_is_public'), + ] + + operations = [ + migrations.AddField( + model_name='scoredimension', + name='is_peer_review', + field=models.BooleanField(default=False, help_text='如果开启,此评分维度仅在选手互评时可见,评委和嘉宾看不到', verbose_name='是否用于选手互评'), + ), + ] diff --git a/backend/competition/migrations/0007_carouselitem_homepageconfig_alter_comment_id_and_more.py b/backend/competition/migrations/0007_carouselitem_homepageconfig_alter_comment_id_and_more.py new file mode 100644 index 0000000..2ee1d80 --- /dev/null +++ b/backend/competition/migrations/0007_carouselitem_homepageconfig_alter_comment_id_and_more.py @@ -0,0 +1,97 @@ +# Generated by Django 4.2.29 on 2026-03-18 09:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0006_add_peer_review_field'), + ] + + operations = [ + migrations.CreateModel( + name='CarouselItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('carousel_type', models.CharField(choices=[('carousel1', '创业大赛轮播图'), ('carousel2', '创业活动轮播图')], default='carousel1', max_length=20, verbose_name='轮播图类型')), + ('image', models.ImageField(blank=True, null=True, upload_to='homepage/carousel/', verbose_name='轮播图片')), + ('image_url', models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='图片URL')), + ('title', models.CharField(max_length=100, verbose_name='标题')), + ('subtitle', models.CharField(max_length=200, verbose_name='副标题')), + ('status', models.CharField(choices=[('报名中', '报名中'), ('即将开始', '即将开始'), ('敬请期待', '敬请期待'), ('进行中', '进行中')], default='报名中', max_length=20, verbose_name='状态')), + ('status_color', models.CharField(default='#52c41a', max_length=20, verbose_name='状态颜色')), + ('date', models.CharField(max_length=100, verbose_name='日期')), + ('location', models.CharField(max_length=100, verbose_name='地点')), + ('order', models.IntegerField(default=0, verbose_name='排序')), + ('is_active', models.BooleanField(default=True, verbose_name='是否显示')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '轮播图项目', + 'verbose_name_plural': '轮播图管理', + 'ordering': ['order', 'id'], + }, + ), + migrations.CreateModel( + name='HomePageConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('banner_image', models.ImageField(blank=True, null=True, upload_to='homepage/', verbose_name='首页Banner图片')), + ('banner_image_url', models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='Banner图片URL')), + ('main_title', models.CharField(default='"创赢未来"云南2026创业大赛', max_length=200, verbose_name='主标题')), + ('carousel1_title', models.CharField(default='"创赢未来"云南2026创业大赛', max_length=200, verbose_name='轮播图1标题')), + ('carousel2_title', models.CharField(default='"七彩云南创业福地"创业主题系列活动', max_length=200, verbose_name='轮播图2标题')), + ('organizer', models.CharField(default='云南省人力资源和社会保障厅', max_length=200, verbose_name='主办单位')), + ('undertaker', models.CharField(default='云南省就业局', max_length=200, verbose_name='承办单位')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '首页配置', + 'verbose_name_plural': '首页配置', + }, + ), + migrations.AlterField( + model_name='comment', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='competition', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='competitionenrollment', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='project', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='projectfile', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='score', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='scoredimension', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='scoredimension', + name='weight', + field=models.DecimalField(decimal_places=4, default=1.0, help_text='例如 0.3000 表示 30%', max_digits=6, verbose_name='权重'), + ), + ] diff --git a/backend/competition/migrations/0008_alter_carouselitem_image_url.py b/backend/competition/migrations/0008_alter_carouselitem_image_url.py new file mode 100644 index 0000000..ac89009 --- /dev/null +++ b/backend/competition/migrations/0008_alter_carouselitem_image_url.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.29 on 2026-03-18 12:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('competition', '0007_carouselitem_homepageconfig_alter_comment_id_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='carouselitem', + name='image_url', + field=models.CharField(blank=True, help_text='可填写本地路径如 /carousel1.png 或完整URL,优先使用上方上传的图片', max_length=500, null=True, verbose_name='图片URL'), + ), + ] diff --git a/backend/competition/migrations/__init__.py b/backend/competition/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/competition/models.py b/backend/competition/models.py new file mode 100644 index 0000000..c3f89bc --- /dev/null +++ b/backend/competition/models.py @@ -0,0 +1,337 @@ +from django.db import models +from shop.models import WeChatUser + + +class HomePageConfig(models.Model): + """首页配置""" + banner_image = models.ImageField(upload_to='homepage/', verbose_name="首页Banner图片", null=True, blank=True) + banner_image_url = models.URLField(verbose_name="Banner图片URL", null=True, blank=True, help_text="优先使用上传的图片") + + main_title = models.CharField(max_length=200, default='"创赢未来"云南2026创业大赛', verbose_name="主标题") + + carousel1_title = models.CharField(max_length=200, default='"创赢未来"云南2026创业大赛', verbose_name="轮播图1标题") + carousel2_title = models.CharField(max_length=200, default='"七彩云南创业福地"创业主题系列活动', verbose_name="轮播图2标题") + + organizer = models.CharField(max_length=200, default='云南省人力资源和社会保障厅', verbose_name="主办单位") + undertaker = models.CharField(max_length=200, default='云南省就业局', verbose_name="承办单位") + + is_active = models.BooleanField(default=True, verbose_name="是否启用") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + class Meta: + verbose_name = "首页配置" + verbose_name_plural = "首页配置" + + def __str__(self): + return "首页配置" + + def get_banner_url(self): + if self.banner_image: + return self.banner_image.url + return self.banner_image_url + + +class CarouselItem(models.Model): + """轮播图项目""" + CAROUSEL_TYPE_CHOICES = ( + ('carousel1', '创业大赛轮播图'), + ('carousel2', '创业活动轮播图'), + ) + + STATUS_CHOICES = ( + ('报名中', '报名中'), + ('即将开始', '即将开始'), + ('敬请期待', '敬请期待'), + ('进行中', '进行中'), + ) + + carousel_type = models.CharField(max_length=20, choices=CAROUSEL_TYPE_CHOICES, default='carousel1', verbose_name="轮播图类型") + + image = models.ImageField(upload_to='homepage/carousel/', verbose_name="轮播图片", null=True, blank=True) + image_url = models.CharField(max_length=500, verbose_name="图片URL", null=True, blank=True, help_text="可填写本地路径如 /carousel1.png 或完整URL,优先使用上方上传的图片") + + title = models.CharField(max_length=100, verbose_name="标题") + subtitle = models.CharField(max_length=200, verbose_name="副标题") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='报名中', verbose_name="状态") + status_color = models.CharField(max_length=20, default='#52c41a', verbose_name="状态颜色") + + date = models.CharField(max_length=100, verbose_name="日期") + location = models.CharField(max_length=100, verbose_name="地点") + + order = models.IntegerField(default=0, verbose_name="排序") + is_active = models.BooleanField(default=True, verbose_name="是否显示") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + class Meta: + verbose_name = "轮播图项目" + verbose_name_plural = "轮播图管理" + ordering = ['order', 'id'] + + def __str__(self): + return f"{self.get_carousel_type_display()} - {self.title}" + + def get_image_url(self): + if self.image: + return self.image.url + return self.image_url + + +class Competition(models.Model): + """ + 比赛管理模型 + """ + STATUS_CHOICES = ( + ('draft', '草稿'), + ('published', '已发布'), + ('registration', '报名中'), + ('submission', '作品提交中'), + ('judging', '评审中'), + ('ended', '已结束'), + ) + + PROJECT_VISIBILITY_CHOICES = ( + ('public', '公开可见'), + ('contestant', '选手及以上可见'), + ('guest', '嘉宾及评委可见'), + ('judge', '仅评委可见'), + ) + + title = models.CharField(max_length=200, verbose_name="比赛名称") + description = models.TextField(verbose_name="比赛简介") + rule_description = models.TextField(verbose_name="规则说明") + condition_description = models.TextField(verbose_name="参赛条件说明", blank=True) + + cover_image = models.ImageField(upload_to='competitions/covers/', verbose_name="封面图", null=True, blank=True) + cover_image_url = models.URLField(verbose_name="封面图URL", null=True, blank=True, help_text="优先使用上传的图片") + + start_time = models.DateTimeField(verbose_name="开始时间") + end_time = models.DateTimeField(verbose_name="结束时间") + + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态") + project_visibility = models.CharField(max_length=20, choices=PROJECT_VISIBILITY_CHOICES, default='public', verbose_name="项目可见性") + + allow_contestant_grading = models.BooleanField(default=False, verbose_name="允许选手互评") + + is_active = models.BooleanField(default=True, verbose_name="是否启用") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + def __str__(self): + return self.title + + class Meta: + verbose_name = "比赛" + verbose_name_plural = "比赛管理" + ordering = ['-created_at'] + + +class CompetitionEnrollment(models.Model): + """ + 比赛人员报名/角色分配 + """ + ROLE_CHOICES = ( + ('contestant', '选手'), + ('judge', '评委'), + ('guest', '嘉宾'), + ) + + STATUS_CHOICES = ( + ('pending', '待审核'), + ('approved', '已通过'), + ('rejected', '已拒绝'), + ) + + competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='enrollments', verbose_name="所属比赛") + user = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='competitions', verbose_name="用户") + + role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='contestant', verbose_name="角色") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态") + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="申请时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + class Meta: + verbose_name = "比赛人员" + verbose_name_plural = "人员管理" + unique_together = ('competition', 'user') + + def __str__(self): + return f"{self.competition.title} - {self.user.nickname} ({self.get_role_display()})" + + +class ScoreDimension(models.Model): + """ + 评分维度配置 + """ + competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='score_dimensions', verbose_name="所属比赛") + name = models.CharField(max_length=100, verbose_name="维度名称") + description = models.TextField(verbose_name="维度说明", blank=True) + weight = models.DecimalField(max_digits=6, decimal_places=4, default=1.0000, verbose_name="权重", help_text="例如 0.3000 表示 30%") + max_score = models.IntegerField(default=100, verbose_name="满分值") + + is_public = models.BooleanField(default=True, verbose_name="是否公开给评委", help_text="如果关闭,评委端将看不到此评分维度,通常用于AI自动评分") + + is_peer_review = models.BooleanField(default=False, verbose_name="是否用于选手互评", help_text="如果开启,此评分维度仅在选手互评时可见,评委和嘉宾看不到") + + order = models.IntegerField(default=0, verbose_name="排序权重") + + class Meta: + verbose_name = "评分维度" + verbose_name_plural = "评分维度配置" + ordering = ['order'] + + def __str__(self): + return f"{self.competition.title} - {self.name}" + + +class Project(models.Model): + """ + 参赛项目/作品 + """ + STATUS_CHOICES = ( + ('draft', '草稿'), + ('submitted', '已提交'), + ) + + competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='projects', verbose_name="所属比赛") + contestant = models.ForeignKey(CompetitionEnrollment, on_delete=models.CASCADE, related_name='projects', verbose_name="参赛选手") + + title = models.CharField(max_length=200, verbose_name="项目名称") + description = models.TextField(verbose_name="项目介绍") + team_info = models.TextField(verbose_name="团队介绍", blank=True) + + cover_image = models.ImageField(upload_to='competitions/projects/covers/', verbose_name="项目封面", null=True, blank=True) + cover_image_url = models.URLField(verbose_name="项目封面URL", null=True, blank=True, help_text="优先使用上传的图片") + + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态") + + # 最终得分缓存,避免每次实时计算 + final_score = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name="最终得分") + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + class Meta: + verbose_name = "参赛项目" + verbose_name_plural = "项目管理" + ordering = ['-final_score', '-created_at'] + + def __str__(self): + return self.title + + def calculate_score(self): + """ + 计算项目得分 + 计算公式: + 1. 获取所有评委对该项目的打分 + 2. 每个评委的得分 = sum(维度分数 × 维度权重) + 3. 项目最终得分 = 所有评委得分的平均值 + """ + # 获取所有评分 + scores = self.scores.all() + if not scores.exists(): + return 0 + + # 找出所有参与评分的评委 + judges = set(score.judge for score in scores) + if not judges: + return 0 + + total_weighted_score = 0 + + for judge in judges: + judge_score = 0 + # 获取该评委对该项目的所有维度打分 + judge_scores = scores.filter(judge=judge) + + for score in judge_scores: + # 直接用原始分数乘以权重相加 + judge_score += score.score * score.dimension.weight + + total_weighted_score += judge_score + + # 平均分 + avg_score = total_weighted_score / len(judges) + self.final_score = avg_score + self.save() + return avg_score + + +class ProjectFile(models.Model): + """ + 项目附件 + """ + FILE_TYPE_CHOICES = ( + ('ppt', 'PPT演示文稿'), + ('pdf', 'PDF文档'), + ('image', '图片'), + ('video', '视频'), + ('doc', '文档'), + ('other', '其他'), + ) + + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='files', verbose_name="所属项目") + file_type = models.CharField(max_length=20, choices=FILE_TYPE_CHOICES, default='other', verbose_name="文件类型") + + file = models.FileField(upload_to='competitions/projects/files/', verbose_name="文件", null=True, blank=True) + file_url = models.URLField(verbose_name="文件链接", null=True, blank=True, help_text="视频等大文件建议使用外部链接") + + name = models.CharField(max_length=100, verbose_name="文件名称", blank=True) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间") + + class Meta: + verbose_name = "项目附件" + verbose_name_plural = "附件管理" + + def __str__(self): + return self.name or f"{self.get_file_type_display()}" + + +class Score(models.Model): + """ + 评委打分 + """ + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='scores', verbose_name="所属项目") + judge = models.ForeignKey(CompetitionEnrollment, on_delete=models.CASCADE, related_name='given_scores', verbose_name="评委") + dimension = models.ForeignKey(ScoreDimension, on_delete=models.CASCADE, verbose_name="评分维度") + + score = models.DecimalField(max_digits=5, decimal_places=1, verbose_name="得分") + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="打分时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + class Meta: + verbose_name = "评分记录" + verbose_name_plural = "评分记录" + unique_together = ('project', 'judge', 'dimension') + + def __str__(self): + return f"{self.judge.user.nickname} -> {self.project.title}: {self.score}" + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + # 触发重新计算分数 + self.project.calculate_score() + + +class Comment(models.Model): + """ + 评委评语 + """ + project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='comments', verbose_name="所属项目") + judge = models.ForeignKey(CompetitionEnrollment, on_delete=models.CASCADE, related_name='given_comments', verbose_name="评委") + + content = models.TextField(verbose_name="评语内容") + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="评论时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + class Meta: + verbose_name = "评委评语" + verbose_name_plural = "评语管理" + + def __str__(self): + return f"{self.judge.user.nickname} -> {self.project.title}" diff --git a/backend/competition/serializers.py b/backend/competition/serializers.py new file mode 100644 index 0000000..62ec478 --- /dev/null +++ b/backend/competition/serializers.py @@ -0,0 +1,155 @@ +from rest_framework import serializers +from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, HomePageConfig, CarouselItem +from shop.serializers import WeChatUserSerializer + + +class CarouselItemSerializer(serializers.ModelSerializer): + display_image = serializers.SerializerMethodField() + + class Meta: + model = CarouselItem + fields = ['id', 'carousel_type', 'image', 'image_url', 'display_image', + 'title', 'subtitle', 'status', 'status_color', 'date', 'location', + 'order', 'is_active'] + + def get_display_image(self, obj): + request = self.context.get('request') + if obj.image: + if request: + return request.build_absolute_uri(obj.image.url) + return obj.image.url + return obj.image_url + + +class HomePageConfigSerializer(serializers.ModelSerializer): + display_banner = serializers.SerializerMethodField() + carousel1_items = serializers.SerializerMethodField() + carousel2_items = serializers.SerializerMethodField() + + class Meta: + model = HomePageConfig + fields = ['id', 'banner_image', 'banner_image_url', 'display_banner', + 'main_title', 'carousel1_title', 'carousel2_title', + 'organizer', 'undertaker', 'carousel1_items', 'carousel2_items'] + + def get_display_banner(self, obj): + request = self.context.get('request') + if obj.banner_image: + if request: + return request.build_absolute_uri(obj.banner_image.url) + return obj.banner_image.url + return obj.banner_image_url + + def get_carousel1_items(self, obj): + items = CarouselItem.objects.filter(carousel_type='carousel1', is_active=True) + return CarouselItemSerializer(items, many=True, context=self.context).data + + def get_carousel2_items(self, obj): + items = CarouselItem.objects.filter(carousel_type='carousel2', is_active=True) + return CarouselItemSerializer(items, many=True, context=self.context).data + + +class ScoreDimensionSerializer(serializers.ModelSerializer): + class Meta: + model = ScoreDimension + fields = ['id', 'name', 'description', 'weight', 'max_score', 'order'] + +class CompetitionSerializer(serializers.ModelSerializer): + score_dimensions = ScoreDimensionSerializer(many=True, read_only=True) + display_cover_image = serializers.SerializerMethodField() + status_display = serializers.CharField(source='get_status_display', read_only=True) + + class Meta: + model = Competition + fields = ['id', 'title', 'description', 'rule_description', 'condition_description', + 'cover_image', 'cover_image_url', 'display_cover_image', + 'start_time', 'end_time', 'status', 'project_visibility', 'status_display', 'is_active', + 'score_dimensions', 'created_at'] + + def get_display_cover_image(self, obj): + request = self.context.get('request') + if obj.cover_image: + if request: + return request.build_absolute_uri(obj.cover_image.url) + return obj.cover_image.url + return obj.cover_image_url + +class CompetitionEnrollmentSerializer(serializers.ModelSerializer): + user = WeChatUserSerializer(read_only=True) + + class Meta: + model = CompetitionEnrollment + fields = ['id', 'competition', 'user', 'role', 'status', 'created_at'] + read_only_fields = ['status'] + +class ProjectFileSerializer(serializers.ModelSerializer): + class Meta: + model = ProjectFile + fields = ['id', 'project', 'file_type', 'file', 'file_url', 'name', 'created_at'] + + def validate_file(self, value): + if not value: + return value + # 50MB limit + limit_mb = 50 + if value.size > limit_mb * 1024 * 1024: + raise serializers.ValidationError(f"文件大小不能超过 {limit_mb}MB") + return value + +class ProjectSerializer(serializers.ModelSerializer): + files = ProjectFileSerializer(many=True, read_only=True) + contestant_info = serializers.SerializerMethodField() + display_cover_image = serializers.SerializerMethodField() + + class Meta: + model = Project + fields = ['id', 'competition', 'contestant', 'title', 'description', 'team_info', + 'cover_image', 'cover_image_url', 'display_cover_image', + 'status', 'final_score', 'files', 'contestant_info', 'created_at'] + read_only_fields = ['final_score', 'contestant'] + + def get_contestant_info(self, obj): + return { + "nickname": obj.contestant.user.nickname, + "avatar_url": obj.contestant.user.avatar_url + } + + def get_display_cover_image(self, obj): + if obj.cover_image: + return obj.cover_image.url + return obj.cover_image_url + +class ScoreSerializer(serializers.ModelSerializer): + judge_name = serializers.CharField(source='judge.user.nickname', read_only=True) + dimension_name = serializers.CharField(source='dimension.name', read_only=True) + + class Meta: + model = Score + fields = ['id', 'project', 'judge', 'dimension', 'score', 'judge_name', 'dimension_name', 'created_at'] + read_only_fields = ['judge'] + +class CommentSerializer(serializers.ModelSerializer): + judge_name = serializers.CharField(source='judge.user.nickname', read_only=True) + score = serializers.SerializerMethodField() + + class Meta: + model = Comment + fields = ['id', 'project', 'judge', 'content', 'judge_name', 'created_at', 'score'] + read_only_fields = ['judge'] + + def get_score(self, obj): + scores = Score.objects.filter(project=obj.project, judge=obj.judge) + if not scores.exists(): + return None + + current_judge_total_score = 0 + current_judge_total_weight = 0 + + for score in scores: + current_judge_total_score += score.score * score.dimension.weight + current_judge_total_weight += score.dimension.weight + + if current_judge_total_weight > 0: + judge_score = current_judge_total_score / current_judge_total_weight + return round(judge_score, 1) + return None diff --git a/backend/competition/templates/judge/ai_manage.html b/backend/competition/templates/judge/ai_manage.html new file mode 100644 index 0000000..7b35ba1 --- /dev/null +++ b/backend/competition/templates/judge/ai_manage.html @@ -0,0 +1,179 @@ +{% extends 'judge/base.html' %} + +{% block title %}AI 服务管理 - 评委系统{% endblock %} + +{% block content %} +
+

AI 服务管理

+

查看和管理音频转录及 AI 评分任务

+
+ +
+
+ + + + + + + + + + + + {% for task in tasks %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
+ 项目 + + 文件名 + + 状态 + + AI 评分 + + 操作 +
+
{{ task.project.title }}
+
+ + {{ task.file_name|default:"查看文件"|truncatechars:20 }} + + + + {{ task.get_status_display }} + + + {% if task.ai_score %} + {{ task.ai_score }} 分 + {% else %} + - + {% endif %} + + + {% if task.status == 'SUCCEEDED' %} + + {% endif %} + +
+
+ +

暂无 AI 任务

+
+
+
+
+ + + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/competition/templates/judge/base.html b/backend/competition/templates/judge/base.html new file mode 100644 index 0000000..834b5e8 --- /dev/null +++ b/backend/competition/templates/judge/base.html @@ -0,0 +1,235 @@ + + + + + + {% block title %}评委系统{% endblock %} + + + + + + {% block extra_css %}{% endblock %} + + + {% if request.session.judge_id %} +
+
+
+
+ + +

评委评分系统

+
+ +
+
+ + +
+
+
+ + +
+
+ {{ request.session.judge_name }} + + {% if request.session.judge_role == 'judge' %}评委 + {% elif request.session.judge_role == 'guest' %}嘉宾 + {% elif request.session.judge_role == 'contestant' %}选手 + {% else %}{{ request.session.judge_role }}{% endif %} + +
+
+ + 项目列表 + + {% if request.session.judge_role == 'judge' or request.session.judge_role == 'guest' %} + + AI管理 + + {% endif %} +
+
+
+ {% endif %} + +
+ {% if messages %} +
+ {% for message in messages %} +
+ +

{{ message }}

+
+ {% endfor %} +
+ {% endif %} + + {% block content %}{% endblock %} +
+ +
+
+

+ © {% now "Y" %} 评委评分系统. All rights reserved. +

+
+
+ + + {% block extra_js %}{% endblock %} + + diff --git a/backend/competition/templates/judge/dashboard.html b/backend/competition/templates/judge/dashboard.html new file mode 100644 index 0000000..9841940 --- /dev/null +++ b/backend/competition/templates/judge/dashboard.html @@ -0,0 +1,823 @@ +{% extends 'judge/base.html' %} + +{% block title %}项目列表 - 评委系统{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+

参赛项目列表

+

请对以下分配给您的项目进行评审

+
+ {% if request.session.judge_role == 'judge' or request.session.judge_role == 'guest' %} + + {% endif %} +
+ +
+ {% for project in projects %} +
+
+ {% if project.cover_image_url %} + {{ project.title }} + {% else %} +
+ + 暂无封面 +
+ {% endif %} +
+ + {{ project.get_status_display }} + +
+
+ +
+

{{ project.title }}

+
+ + {{ project.contestant_name }} +
+ +
+
+ 当前得分 + {{ project.current_score|default:"--" }} +
+ +
+
+
+ {% empty %} +
+
+
+ +
+

暂无项目

+

当前没有分配给您的参赛项目。

+
+
+ {% endfor %} +
+ + + + + + + + + + +{% endblock %} + +{% block extra_js %} + + +{% endblock %} diff --git a/backend/competition/templates/judge/login.html b/backend/competition/templates/judge/login.html new file mode 100644 index 0000000..a9ad9ac --- /dev/null +++ b/backend/competition/templates/judge/login.html @@ -0,0 +1,129 @@ +{% extends 'judge/base.html' %} + +{% block title %}评委登录{% endblock %} + +{% block content %} +
+
+
+
+ +
+

+ 评委登录 +

+

+ 请输入您的手机号验证登录 +

+
+ +
+ {% csrf_token %} +
+
+ +
+
+ +
+ +
+
+
+ +
+
+
+ +
+ +
+ +
+
+
+ +
+ +
+
+ + {% if error %} +
+
+
+ +
+
+

登录失败

+
+

{{ error }}

+
+
+
+
+ {% endif %} +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/backend/competition/tests.py b/backend/competition/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/competition/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/competition/urls.py b/backend/competition/urls.py new file mode 100644 index 0000000..60bab79 --- /dev/null +++ b/backend/competition/urls.py @@ -0,0 +1,27 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import ( + CompetitionViewSet, ProjectViewSet, ProjectFileViewSet, + ScoreViewSet, CommentViewSet, CarouselItemViewSet, get_homepage_config +) +from . import judge_views + +router = DefaultRouter() +router.register(r'competitions', CompetitionViewSet) +router.register(r'projects', ProjectViewSet, basename='project') +router.register(r'files', ProjectFileViewSet, basename='projectfile') +router.register(r'scores', ScoreViewSet, basename='score') +router.register(r'comments', CommentViewSet, basename='comment') +router.register(r'carousel-items', CarouselItemViewSet, basename='carouselitem') + +urlpatterns = [ + # 首页配置 + path('homepage-config/', get_homepage_config, name='homepage-config'), + + # Judge System Routes + path('admin/', judge_views.admin_entry, name='judge_admin_entry'), + path('judge/', include('competition.judge_urls')), # Sub-routes under /judge/ + + # Existing API Routes + path('', include(router.urls)), +] diff --git a/backend/competition/views.py b/backend/competition/views.py new file mode 100644 index 0000000..2d07c6b --- /dev/null +++ b/backend/competition/views.py @@ -0,0 +1,319 @@ +from rest_framework import viewsets, permissions, status, filters, serializers +from rest_framework.decorators import action, api_view, permission_classes +from rest_framework.response import Response +from django.db.models import Q +from shop.utils import get_current_wechat_user +from .models import Competition, CompetitionEnrollment, Project, ProjectFile, Score, Comment, ScoreDimension, HomePageConfig, CarouselItem +from .serializers import ( + CompetitionSerializer, CompetitionEnrollmentSerializer, + ProjectSerializer, ProjectFileSerializer, + ScoreSerializer, CommentSerializer, ScoreDimensionSerializer, + HomePageConfigSerializer, CarouselItemSerializer +) + +from rest_framework.pagination import PageNumberPagination + + +@api_view(['GET']) +@permission_classes([permissions.AllowAny]) +def get_homepage_config(request): + """获取首页配置""" + try: + config = HomePageConfig.objects.filter(is_active=True).first() + if not config: + config = HomePageConfig.objects.create() + serializer = HomePageConfigSerializer(config, context={'request': request}) + return Response(serializer.data) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class CarouselItemViewSet(viewsets.ModelViewSet): + """轮播图项目管理""" + queryset = CarouselItem.objects.all() + serializer_class = CarouselItemSerializer + permission_classes = [permissions.AllowAny] + filter_backends = [filters.SearchFilter] + search_fields = ['title'] + + def get_queryset(self): + queryset = CarouselItem.objects.all() + carousel_type = self.request.query_params.get('carousel_type') + if carousel_type: + queryset = queryset.filter(carousel_type=carousel_type) + return queryset + + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = 'page_size' + max_page_size = 100 + +class CompetitionViewSet(viewsets.ReadOnlyModelViewSet): + """ + 比赛视图集 + """ + queryset = Competition.objects.filter(is_active=True).order_by('created_at') + serializer_class = CompetitionSerializer + permission_classes = [permissions.AllowAny] + pagination_class = StandardResultsSetPagination + filter_backends = [filters.SearchFilter] + search_fields = ['title', 'description'] + + def get_serializer_context(self): + context = super().get_serializer_context() + context['request'] = self.request + return context + + def get_queryset(self): + """ + 获取比赛查询集,支持根据查询参数进行动态过滤 + """ + queryset = super().get_queryset() + + # 状态过滤 + status_param = self.request.query_params.get('status') + if status_param and status_param != 'all': + queryset = queryset.filter(status=status_param) + + return queryset + + @action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny]) + def enroll(self, request, pk=None): + """ + 报名参加比赛 + """ + competition = self.get_object() + user = get_current_wechat_user(request) + if not user: + return Response({"detail": "未登录"}, status=status.HTTP_401_UNAUTHORIZED) + + role = request.data.get('role', 'contestant') + + # 检查是否已报名 + if CompetitionEnrollment.objects.filter(competition=competition, user=user).exists(): + return Response({"detail": "您已报名该比赛"}, status=status.HTTP_400_BAD_REQUEST) + + enrollment = CompetitionEnrollment.objects.create( + competition=competition, + user=user, + role=role, + status='pending' # 默认待审核 + ) + + return Response(CompetitionEnrollmentSerializer(enrollment).data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['get']) + def my_enrollment(self, request, pk=None): + """ + 获取我的报名信息 + """ + competition = self.get_object() + user = get_current_wechat_user(request) + if not user: + return Response({"detail": "未登录"}, status=status.HTTP_401_UNAUTHORIZED) + + try: + enrollment = CompetitionEnrollment.objects.get(competition=competition, user=user) + return Response(CompetitionEnrollmentSerializer(enrollment).data) + except CompetitionEnrollment.DoesNotExist: + return Response({"detail": "未报名"}, status=status.HTTP_404_NOT_FOUND) + + @action(detail=False, methods=['get']) + def my_enrollments(self, request): + """ + 获取我的所有报名信息 + """ + user = get_current_wechat_user(request) + if not user: + return Response([]) + enrollments = CompetitionEnrollment.objects.filter(user=user) + return Response(CompetitionEnrollmentSerializer(enrollments, many=True).data) + + +class ProjectViewSet(viewsets.ModelViewSet): + """ + 参赛项目视图集 + """ + serializer_class = ProjectSerializer + permission_classes = [permissions.AllowAny] + pagination_class = StandardResultsSetPagination + + def get_queryset(self): + queryset = Project.objects.all() + competition_id = self.request.query_params.get('competition') + if competition_id: + queryset = queryset.filter(competition_id=competition_id) + + contestant_id = self.request.query_params.get('contestant') + if contestant_id: + queryset = queryset.filter(contestant_id=contestant_id) + + user = get_current_wechat_user(self.request) + + # 1. 基础条件:公开可见且已提交的项目 + q = Q(competition__project_visibility='public', status='submitted') + + if user: + # 2. 用户自己的项目(始终可见,包括草稿) + q |= Q(contestant__user=user) + + # 3. 基于角色的可见性 + # 获取用户已通过审核的报名信息 + enrollments = CompetitionEnrollment.objects.filter(user=user, status='approved') + + # 获取各角色的比赛ID集合 + judge_comp_ids = set(enrollments.filter(role='judge').values_list('competition_id', flat=True)) + guest_comp_ids = set(enrollments.filter(role='guest').values_list('competition_id', flat=True)) + contestant_comp_ids = set(enrollments.filter(role='contestant').values_list('competition_id', flat=True)) + + # 'judge' 可见性:仅评委可见 + if judge_comp_ids: + q |= Q(competition__project_visibility='judge', competition__in=judge_comp_ids, status='submitted') + + # 'guest' 可见性:嘉宾及评委可见 + guest_access_ids = judge_comp_ids | guest_comp_ids + if guest_access_ids: + q |= Q(competition__project_visibility='guest', competition__in=guest_access_ids, status='submitted') + + # 'contestant' 可见性:选手及以上可见(包括评委、嘉宾) + contestant_access_ids = judge_comp_ids | guest_comp_ids | contestant_comp_ids + if contestant_access_ids: + q |= Q(competition__project_visibility='contestant', competition__in=contestant_access_ids, status='submitted') + + queryset = queryset.filter(q) + + return queryset.order_by('-final_score', '-created_at') + + def perform_create(self, serializer): + user = get_current_wechat_user(self.request) + if not user: + raise serializers.ValidationError("请先登录") + + competition = serializer.validated_data['competition'] + + # 检查是否有参赛资格 + try: + enrollment = CompetitionEnrollment.objects.get( + competition=competition, + user=user, + role='contestant', + status='approved' + ) + except CompetitionEnrollment.DoesNotExist: + raise serializers.ValidationError("您没有参赛资格或审核未通过") + + # 检查是否已提交过项目 + if Project.objects.filter(competition=competition, contestant=enrollment).exists(): + raise serializers.ValidationError("您已提交过该比赛的项目,请勿重复提交") + + serializer.save(contestant=enrollment) + + @action(detail=True, methods=['post']) + def submit(self, request, pk=None): + """ + 提交项目(从草稿转为已提交) + """ + project = self.get_object() + user = get_current_wechat_user(request) + + if project.contestant.user != user: + return Response({"detail": "无权操作"}, status=status.HTTP_403_FORBIDDEN) + + project.status = 'submitted' + project.save() + return Response({"status": "submitted"}) + + +class ProjectFileViewSet(viewsets.ModelViewSet): + """ + 项目附件管理 + """ + serializer_class = ProjectFileSerializer + permission_classes = [permissions.AllowAny] + + def get_queryset(self): + return ProjectFile.objects.all() + + def perform_create(self, serializer): + # 简单权限控制:只有项目拥有者可以上传 + project = serializer.validated_data['project'] + user = get_current_wechat_user(self.request) + + if not user or project.contestant.user != user: + raise serializers.ValidationError("无权上传文件") + + serializer.save() + + +class ScoreViewSet(viewsets.ModelViewSet): + """ + 评分管理 + """ + serializer_class = ScoreSerializer + permission_classes = [permissions.AllowAny] + + def get_queryset(self): + project_id = self.request.query_params.get('project') + if project_id: + return Score.objects.filter(project_id=project_id) + return Score.objects.all() + + def perform_create(self, serializer): + user = get_current_wechat_user(self.request) + if not user: + raise serializers.ValidationError("请先登录") + + project = serializer.validated_data['project'] + + # 检查是否是评委 + try: + enrollment = CompetitionEnrollment.objects.get( + competition=project.competition, + user=user, + role='judge', + status='approved' + ) + except CompetitionEnrollment.DoesNotExist: + raise serializers.ValidationError("您不是该比赛的评委") + + # 检查是否重复打分 + dimension = serializer.validated_data['dimension'] + if Score.objects.filter(project=project, judge=enrollment, dimension=dimension).exists(): + raise serializers.ValidationError("您已对该维度打分") + + serializer.save(judge=enrollment) + + +class CommentViewSet(viewsets.ModelViewSet): + """ + 评语管理 + """ + serializer_class = CommentSerializer + permission_classes = [permissions.AllowAny] + + def get_queryset(self): + project_id = self.request.query_params.get('project') + if project_id: + return Comment.objects.filter(project_id=project_id) + return Comment.objects.all() + + def perform_create(self, serializer): + user = get_current_wechat_user(self.request) + if not user: + raise serializers.ValidationError("请先登录") + + project = serializer.validated_data['project'] + + # 检查是否是评委 + try: + enrollment = CompetitionEnrollment.objects.get( + competition=project.competition, + user=user, + role='judge', + status='approved' + ) + except CompetitionEnrollment.DoesNotExist: + raise serializers.ValidationError("您不是该比赛的评委") + + serializer.save(judge=enrollment) diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..ffbb5f5 --- /dev/null +++ b/backend/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..d7f69f3 --- /dev/null +++ b/backend/config/settings.py @@ -0,0 +1,404 @@ +""" +Django settings for config project. + +Generated by 'django-admin startproject' using Django 6.0.1. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/6.0/ref/settings/ +""" + +import os +from pathlib import Path +from dotenv import load_dotenv + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Load .env file +load_dotenv(BASE_DIR / '.env') + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-9hwh_v44(3n)61g)tiwkvm1k0h&5c+u=68&z*!$e0ujpd-6^1o' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ["*"] + + +# Application definition + +INSTALLED_APPS = [ + 'unfold', # django-unfold必须在admin之前 + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'rest_framework', + 'corsheaders', + 'django_filters', + 'drf_spectacular', # Swagger文档生成 + 'drf_spectacular_sidecar', + # 'adminsortable2', # 暂时禁用,改用手动设置 + 'shop', + 'community', + 'competition', + 'ai_services', +] + +MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +CORS_ALLOW_ALL_ORIGINS = True + +CSRF_TRUSTED_ORIGINS = [ + "https://market.quant-speed.com", + "http://market.quant-speed.com", + "http://localhost:8000", +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/6.0/ref/settings/#databases + +# 数据库配置:默认使用 SQLite,如果有环境变量配置则使用 PostgreSQL +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + +#从环境变量获取数据库配置 (Docker 环境会自动注入这些变量。 +# 只有当 DB_HOST 被明确设置且不为空时才使用 PostgreSQL +DB_HOST = os.environ.get('DB_HOST', '') +if DB_HOST and DB_HOST != '6.6.6.66': + DATABASES['default'] = { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': os.environ.get('DB_NAME', 'market'), + 'USER': os.environ.get('DB_USER', 'market'), + 'PASSWORD': os.environ.get('DB_PASSWORD', '123market'), + 'HOST': DB_HOST, + 'PORT': os.environ.get('DB_PORT', '5432'), + } + + +# DB_HOST = os.environ.get('DB_HOST', '121.43.104.161') +# if DB_HOST: +# DATABASES['default'] = { +# 'ENGINE': 'django.db.backends.postgresql', +# 'NAME': os.environ.get('DB_NAME', 'market'), +# 'USER': os.environ.get('DB_USER', 'market'), +# 'PASSWORD': os.environ.get('DB_PASSWORD', '123market'), +# 'HOST': DB_HOST, +# 'PORT': os.environ.get('DB_PORT', '6433'), +# } + + +# Password validation +# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/6.0/topics/i18n/ + +LANGUAGE_CODE = 'zh-hans' + +TIME_ZONE = 'Asia/Shanghai' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.0/howto/static-files/ + +STATIC_URL = 'static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' + +# 静态文件配置 +STATICFILES_DIRS = [ + BASE_DIR / 'static', +] + +# 媒体文件配置 +MEDIA_URL = '/media/' +MEDIA_ROOT = BASE_DIR / 'media' + +# Django REST Framework配置 +REST_FRAMEWORK = { + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', + 'DEFAULT_AUTHENTICATION_CLASSES': [], + 'DEFAULT_PERMISSION_CLASSES': [], +} + +# drf-spectacular配置 +SPECTACULAR_SETTINGS = { + 'TITLE': '科技公司产品购买API', + 'DESCRIPTION': '科技公司产品购买官网的API文档', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': True, + 'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'], + 'COMPONENT_SPLIT_REQUEST': True, + 'SWAGGER_UI_DIST': 'SIDECAR', + 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', + 'REDOC_DIST': 'SIDECAR', +} + +from django.urls import reverse_lazy + +# django-unfold配置 +UNFOLD = { + "SITE_TITLE": "创赢未来", + "SITE_HEADER": "创赢未来评分管理后台", + "SITE_URL": "/", + "COLORS": { + "primary": { + "50": "rgb(236 254 255)", + "100": "rgb(207 250 254)", + "200": "rgb(165 243 252)", + "300": "rgb(103 232 249)", + "400": "rgb(34 211 238)", + "500": "rgb(6 182 212)", + "600": "rgb(8 145 178)", + "700": "rgb(14 116 144)", + "800": "rgb(21 94 117)", + "900": "rgb(22 78 99)", + "950": "rgb(8 51 68)", + }, + }, + "SIDEBAR": { + "show_search": True, + "show_all_applications": False, + "navigation": [ + { + "title": "用户管理", + "separator": True, + "items": [ + { + "title": "微信用户", + "icon": "people", + "link": reverse_lazy("admin:shop_wechatuser_changelist"), + }, + ], + }, + { + "title": "首页管理", + "separator": True, + "items": [ + { + "title": "首页配置", + "icon": "home", + "link": reverse_lazy("admin:competition_homepageconfig_changelist"), + }, + { + "title": "轮播图管理", + "icon": "image", + "link": reverse_lazy("admin:competition_carouselitem_changelist"), + }, + ], + }, + { + "title": "比赛管理", + "separator": True, + "items": [ + { + "title": "比赛列表", + "icon": "emoji_events", + "link": reverse_lazy("admin:competition_competition_changelist"), + }, + { + "title": "比赛人员/报名", + "icon": "group_add", + "link": reverse_lazy("admin:competition_competitionenrollment_changelist"), + }, + { + "title": "参赛项目", + "icon": "lightbulb", + "link": reverse_lazy("admin:competition_project_changelist"), + }, + { + "title": "评分记录", + "icon": "score", + "link": reverse_lazy("admin:competition_score_changelist"), + }, + { + "title": "评委评语", + "icon": "rate_review", + "link": reverse_lazy("admin:competition_comment_changelist"), + }, + ], + }, + { + "title": "系列活动", + "separator": True, + "items": [ + { + "title": "活动管理", + "icon": "calendar_today", + "link": reverse_lazy("admin:community_activity_changelist"), + }, + { + "title": "活动报名", + "icon": "how_to_reg", + "link": reverse_lazy("admin:community_activitysignup_changelist"), + }, + ], + }, + { + "title": "课程培训", + "separator": True, + "items": [ + { + "title": "课程管理", + "icon": "school", + "link": reverse_lazy("admin:shop_vccourse_changelist"), + }, + { + "title": "课程报名", + "icon": "menu_book", + "link": reverse_lazy("admin:shop_courseenrollment_changelist"), + }, + ], + }, + { + "title": "身份标签", + "separator": True, + "items": [ + { + "title": "标签管理", + "icon": "label", + "link": reverse_lazy("admin:shop_identitytag_changelist"), + }, + { + "title": "用户身份", + "icon": "person_pin", + "link": reverse_lazy("admin:shop_useridentity_changelist"), + }, + ], + }, + { + "title": "AI 听悟", + "separator": True, + "items": [ + { + "title": "转写与总结任务", + "icon": "record_voice_over", + "link": reverse_lazy("admin:ai_services_transcriptiontask_changelist"), + }, + { + "title": "AI 评估模板", + "icon": "rule", + "link": reverse_lazy("admin:ai_services_aievaluationtemplate_changelist"), + }, + { + "title": "AI 评估结果", + "icon": "psychology", + "link": reverse_lazy("admin:ai_services_aievaluation_changelist"), + }, + ], + }, + { + "title": "系统配置", + "separator": True, + "items": [ + { + "title": "微信支付配置", + "icon": "payment", + "link": reverse_lazy("admin:shop_wechatpayconfig_changelist"), + }, + { + "title": "管理员通知手机号", + "icon": "contact_phone", + "link": reverse_lazy("admin:shop_adminphonenumber_changelist"), + }, + { + "title": "用户认证", + "icon": "security", + "link": reverse_lazy("admin:auth_user_changelist"), + }, + ], + }, + ], + }, +} + +# 重新启用自动补齐斜杠,方便 Admin 使用 +# 微信支付回调接口已在 urls.py 中配置 re_path 兼容无斜杠的情况 +APPEND_SLASH = True + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, +} + +# 阿里云配置 +ALIYUN_ACCESS_KEY_ID = os.environ.get('ALIYUN_ACCESS_KEY_ID', '') +ALIYUN_ACCESS_KEY_SECRET = os.environ.get('ALIYUN_ACCESS_KEY_SECRET', '') +ALIYUN_OSS_BUCKET_NAME = os.environ.get('ALIYUN_OSS_BUCKET_NAME', '') +ALIYUN_OSS_ENDPOINT = os.environ.get('ALIYUN_OSS_ENDPOINT', 'oss-cn-shanghai.aliyuncs.com') +ALIYUN_OSS_INTERNAL_ENDPOINT = os.environ.get('ALIYUN_OSS_INTERNAL_ENDPOINT', '') +ALIYUN_TINGWU_APP_KEY = os.environ.get('ALIYUN_TINGWU_APP_KEY', '') # 听悟AppKey + +DASHSCOPE_API_KEY = os.environ.get('DASHSCOPE_API_KEY', '') diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..809111d --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,29 @@ +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView +from competition import judge_views + +urlpatterns = [ + path('admin/', admin.site.urls), + + # Judge System Routes + path('judge/', include('competition.judge_urls')), + path('competition/admin/', judge_views.admin_entry, name='judge_admin_entry_root'), + + path('api/', include('shop.urls')), + path('api/community/', include('community.urls')), + path('api/competition/', include('competition.urls')), + path('api/ai/', include('ai_services.urls')), + + # Swagger文档路由 + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), +] + +# 静态文件配置(开发环境) +if settings.DEBUG: + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..4ced574 --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..8e7ac79 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/populate_db.py b/backend/populate_db.py new file mode 100644 index 0000000..21ce6bf --- /dev/null +++ b/backend/populate_db.py @@ -0,0 +1,57 @@ +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +django.setup() + +from shop.models import ESP32Config + +def populate(): + # 检查数据库是否已有数据,如果有则跳过,避免每次构建都重置! + if ESP32Config.objects.exists(): + print("ESP32Config data already exists, skipping population.") + return + + # 清除旧数据,避免重复累积 + # 注意:在生产环境中慎用 delete + # ESP32Config.objects.all().delete() + + configs = [ + { + "name": "AI小智 Mini款", + "chip_type": "ESP32-C3", + "flash_size": 4, + "ram_size": 1, + "has_camera": False, + "has_microphone": True, + "price": 150.00, + "description": "高性价比入门款,支持语音交互,小巧便携。" + }, + { + "name": "AI小智 V2款 (舵机版)", + "chip_type": "ESP32-S3", + "flash_size": 8, + "ram_size": 2, + "has_camera": False, + "has_microphone": True, + "price": 188.00, + "description": "升级版性能,支持驱动舵机,适合机器人控制与运动交互。" + }, + { + "name": "AI小智 V3款 (视觉版)", + "chip_type": "ESP32-S3", + "flash_size": 16, + "ram_size": 8, + "has_camera": True, + "has_microphone": True, + "price": 250.00, + "description": "旗舰视觉版,配备摄像头与高性能计算单元,支持视觉识别与复杂AI任务。" + } + ] + + for data in configs: + config = ESP32Config.objects.create(**data) + print(f"Created: {config.name} - ¥{config.price}") + +if __name__ == '__main__': + populate() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..a07d93d --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,31 @@ +asgiref==3.11.0 +attrs==25.4.0 +Django==6.0.1 +django-cors-headers==4.9.0 +django-unfold==0.77.1 +djangorestframework==3.16.1 +drf-spectacular==0.29.0 +inflection==0.5.1 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +pillow==12.1.0 +psycopg2-binary==2.9.11 +PyYAML==6.0.3 +qrcode==8.2 +referencing==0.37.0 +rpds-py==0.30.0 +sqlparse==0.5.5 +uritemplate==4.2.0 +wechatpayv3==2.0.1 +drf-spectacular-sidecar==2026.1.1 +gunicorn==21.2.0 +requests +django-filter +django-admin-sortable2 +openpyxl + +aliyun-python-sdk-core==2.16.0 +aliyun-python-sdk-tingwu==1.0.7 +oss2==2.19.1 +python-dotenv +openai diff --git a/backend/shop/__init__.py b/backend/shop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/shop/admin.py b/backend/shop/admin.py new file mode 100644 index 0000000..376c1a2 --- /dev/null +++ b/backend/shop/admin.py @@ -0,0 +1,548 @@ +from django.contrib import admin +from django.utils.html import format_html +from django.db.models import Sum +from django import forms +from django.urls import path, reverse +from django.shortcuts import redirect +from unfold.admin import ModelAdmin, TabularInline +from unfold.decorators import display +from .models import ESP32Config, Order, Salesperson, WeChatPayConfig, Service, VCCourse, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder, CourseEnrollment, AdminPhoneNumber, IdentityTag, UserIdentity +from .admin_actions import export_to_csv, export_to_excel +import qrcode +from io import BytesIO +import base64 + +# 自定义后台标题 +admin.site.site_header = "创赢未来评分系统" +admin.site.site_title = "创赢未来" +admin.site.index_title = "欢迎使用创赢未来评分系统" + +class OrderableAdminMixin: + """ + 为 Admin 添加排序功能的 Mixin + 提供上移、下移按钮,直接交换 order 值 + """ + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path('/move-up/', self.admin_site.admin_view(self.move_up_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_up'), + path('/move-down/', self.admin_site.admin_view(self.move_down_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_down'), + ] + return custom_urls + urls + + def move_up_view(self, request, object_id): + obj = self.get_object(request, object_id) + if obj: + # 找到排在它前面的一个 (order 小于它的最大值) + prev_obj = self.model.objects.filter(order__lt=obj.order).order_by('-order').first() + if prev_obj: + # 交换 + obj.order, prev_obj.order = prev_obj.order, obj.order + obj.save() + prev_obj.save() + self.message_user(request, f"成功将 {obj} 上移") + else: + # 已经是第一个,或者前面没有更小的 order + # 尝试查找 order 等于它的其他对象(理论上不应发生,但为了稳健) + pass + return redirect(request.META.get('HTTP_REFERER', '..')) + + def move_down_view(self, request, object_id): + obj = self.get_object(request, object_id) + if obj: + # 找到排在它后面的一个 (order 大于它的最小值) + next_obj = self.model.objects.filter(order__gt=obj.order).order_by('order').first() + if next_obj: + # 交换 + obj.order, next_obj.order = next_obj.order, obj.order + obj.save() + next_obj.save() + self.message_user(request, f"成功将 {obj} 下移") + return redirect(request.META.get('HTTP_REFERER', '..')) + + def order_actions(self, obj): + # 只有专家用户才显示排序按钮 + if not getattr(obj, 'is_star', True): # 默认为True是为了兼容其他模型,WeChatUser有is_star字段 + return "默认排序" + + # 使用 inline style 实现基本样式,hover 效果如果不能用 CSS 文件,就只能妥协或者用 onmouseover + btn_style = ( + "display: inline-flex; align-items: center; justify-content: center; " + "width: 26px; height: 26px; border-radius: 6px; " + "background-color: #f3f4f6; color: #4b5563; text-decoration: none; " + "border: 1px solid #e5e7eb; transition: all 0.2s;" + ) + # onmouseover js + hover_js = "this.style.backgroundColor='#dbeafe'; this.style.color='#2563eb'; this.style.borderColor='#bfdbfe';" + out_js = "this.style.backgroundColor='#f3f4f6'; this.style.color='#4b5563'; this.style.borderColor='#e5e7eb';" + + return format_html( + '
' + '' + '' + '' + '{}' + '' + '' + '' + '
', + reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_up', args=[obj.pk]), + btn_style, hover_js, out_js, + obj.order, + reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_down', args=[obj.pk]), + btn_style, hover_js, out_js, + ) + order_actions.short_description = "排序调节" + order_actions.allow_tags = True + + +class ExternalUploadWidget(forms.URLInput): + def __init__(self, upload_url, accept='*', *args, **kwargs): + super().__init__(*args, **kwargs) + self.upload_url = upload_url + self.attrs.update({ + 'class': 'upload-url-input vTextField', + 'data-upload-url': upload_url, + 'data-accept': accept, + 'placeholder': '上传文件后自动生成URL', + 'style': 'width: 100%;' + }) + + class Media: + js = ('shop/js/admin_upload.js',) + css = { + 'all': ('shop/css/admin_upload.css',) + } + +class ESP32ConfigAdminForm(forms.ModelForm): + class Meta: + model = ESP32Config + fields = '__all__' + widgets = { + 'static_image_url': ExternalUploadWidget( + upload_url='https://data.tangledup-ai.com/upload?folder=market_page%2Fhardware_xiaozhi%2Fproduct_static_image', + accept='image/*' + ), + 'model_3d_url': ExternalUploadWidget( + upload_url='https://data.tangledup-ai.com/upload?folder=market_page%2Fhardware_xiaozhi%2Fproduct_3D_image', + accept='.zip' + ), + } + +class ProductFeatureInline(TabularInline): + model = ProductFeature + extra = 1 + fields = ('title', 'description', 'icon_name', 'icon_image', 'icon_url', 'order') + +@admin.register(WeChatPayConfig) +class WeChatPayConfigAdmin(ModelAdmin): + list_display = ('app_id', 'mch_id', 'is_active', 'notify_url', 'updated_at_display') + list_filter = ('is_active',) + search_fields = ('app_id', 'mch_id') + + def updated_at_display(self, obj): + # 假设模型没有 updated_at,如果有可以显示,这里仅作占位或移除 + return "N/A" + updated_at_display.short_description = "更新时间" + + fieldsets = ( + ('核心配置 (登录与支付)', { + 'fields': ('app_id', 'app_secret', 'mch_id', 'is_active'), + 'description': 'AppID 和 AppSecret 是小程序登录和支付的基础凭证。请确保 AppID 与小程序后台一致 (项目中优先使用 wxdf2ca73e6c0929f0)。' + }), + ('微信支付 V3 安全配置 (推荐)', { + 'fields': ('apiv3_key', 'mch_cert_serial_no', 'mch_private_key'), + 'description': '使用 Native 支付必须配置这些项。私钥可以粘贴在这里,或者放在 backend/certs/apiclient_key.pem 文件中。' + }), + ('微信支付 V2 安全配置 (旧版)', { + 'fields': ('api_key',), + 'classes': ('collapse',), + 'description': '仅旧版支付接口需要 API Key (V2)。' + }), + ('回调配置', { + 'fields': ('notify_url',) + }), + ) + +@admin.register(ESP32Config) +class ESP32ConfigAdmin(OrderableAdminMixin, ModelAdmin): + form = ESP32ConfigAdminForm + list_display = ('name', 'chip_type', 'price', 'stock', 'has_camera', 'has_microphone', 'order_actions') + list_filter = ('chip_type', 'has_camera') + search_fields = ('name', 'description') + inlines = [ProductFeatureInline] + fieldsets = ( + ('基本信息', { + 'fields': ('name', 'price', 'stock', 'commission_rate', 'description') + }), + ('硬件参数', { + 'fields': ('chip_type', 'flash_size', 'ram_size', 'has_camera', 'has_microphone') + }), + ('详情页图片', { + 'fields': ('detail_image', 'detail_image_url'), + 'description': '图片上传和URL二选一,优先使用URL' + }), + ('多媒体资源', { + 'fields': ('static_image_url', 'model_3d_url'), + 'description': '产品静态图和3D模型的外部链接' + }), + ) + +@admin.register(Service) +class ServiceAdmin(OrderableAdminMixin, ModelAdmin): + list_display = ('title', 'created_at', 'order_actions') + search_fields = ('title', 'description') + fieldsets = ( + ('基本信息', { + 'fields': ('title', 'description', 'color') + }), + ('价格与交付', { + 'fields': ('price', 'unit', 'delivery_time', 'delivery_content') + }), + ('图标', { + 'fields': ('icon', 'icon_url'), + 'description': '图标上传和URL二选一,优先使用URL' + }), + ('详情页图片', { + 'fields': ('detail_image', 'detail_image_url'), + 'description': '图片上传和URL二选一,优先使用URL' + }), + ('详细内容', { + 'fields': ('features',) + }), + ) + +@admin.register(ServiceOrder) +class ServiceOrderAdmin(ModelAdmin): + list_display = ('id', 'customer_name', 'service', 'total_price', 'status', 'salesperson', 'created_at') + list_filter = ('status', 'service', 'salesperson', 'created_at') + search_fields = ('id', 'customer_name', 'phone_number', 'email') + readonly_fields = ('total_price', 'created_at', 'updated_at') + + fieldsets = ( + ('订单信息', { + 'fields': ('service', 'status', 'total_price', 'created_at') + }), + ('客户信息', { + 'fields': ('customer_name', 'company_name', 'phone_number', 'email', 'requirements') + }), + ('销售归属', { + 'fields': ('salesperson',) + }), + ) + +@admin.register(VCCourse) +class VCCourseAdmin(OrderableAdminMixin, ModelAdmin): + list_display = ('title', 'course_type', 'is_video_course', 'price', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at', 'order_actions') + search_fields = ('title', 'description', 'instructor', 'tag') + list_filter = ('course_type', 'is_video_course', 'instructor', 'tag') + actions = ['reset_ordering'] + + @admin.action(description="重置排序 (按ID顺序)") + def reset_ordering(self, request, queryset): + """ + 将选中的课程(或全部)按ID顺序重新分配order值 + """ + # 如果没有选中任何项,默认处理所有(Django Admin默认行为是选中了才会触发Action,但为了稳健) + # 这里既然是Action,用户必须选中。建议用户选中所有。 + # 为了方便,如果用户只选了一个,我们可以提示他选更多,或者我们其实可以忽略queryset,直接重置所有? + # 通常Action是针对queryset的。 + # 更好的做法:对选中的queryset按ID排序,然后更新order。 + + # 这种实现方式:只重置选中的部分,可能会导致order冲突。 + # 稳妥方式:重置整个表的排序。 + + all_objects = VCCourse.objects.all().order_by('id') + for index, obj in enumerate(all_objects, start=1): + obj.order = index + obj.save(update_fields=['order']) + + self.message_user(request, f"成功重置了 {all_objects.count()} 个课程的排序权重。") + + fieldsets = ( + ('基本信息', { + 'fields': ('title', 'description', 'course_type', 'tag', 'price') + }), + ('视频设置', { + 'fields': ('is_video_course', 'video_url', 'video_embed_code'), + 'description': '设置是否为视频课程及视频链接' + }), + ('课程安排', { + 'fields': ('is_fixed_schedule', 'start_time', 'end_time'), + 'description': '勾选“是否固定时间课程”后,请设置开始和结束时间' + }), + ('讲师信息', { + 'fields': ('instructor', 'instructor_title', 'instructor_desc', 'instructor_avatar', 'instructor_avatar_url'), + 'description': '讲师头像上传和URL二选一,优先使用URL' + }), + ('课程详情', { + 'fields': ('duration', 'lesson_count', 'content') + }), + ('封面', { + 'fields': ('cover_image', 'cover_image_url'), + 'description': '图片上传和URL二选一,优先使用URL' + }), + ('详情页长图', { + 'fields': ('detail_image', 'detail_image_url'), + 'description': '图片上传和URL二选一,优先使用URL' + }), + ) + +@admin.register(CourseEnrollment) +class CourseEnrollmentAdmin(ModelAdmin): + list_display = ('customer_name', 'course', 'phone_number', 'status', 'created_at') + list_filter = ('status', 'course', 'created_at') + search_fields = ('customer_name', 'phone_number', 'wechat_id') + + fieldsets = ( + ('报名信息', { + 'fields': ('course', 'status', 'created_at') + }), + ('客户资料', { + 'fields': ('customer_name', 'phone_number', 'wechat_id', 'email', 'message') + }), + ('销售归属', { + 'fields': ('salesperson', 'distributor') + }), + ) + +# 分销员管理已隐藏 - 取消注册 +# @admin.register(Salesperson) +class SalespersonAdmin(ModelAdmin): + pass + +# 分销佣金记录已隐藏 +# @admin.register(CommissionLog) +class CommissionLogAdmin(ModelAdmin): + list_display = ('id', 'salesperson', 'distributor', 'amount', 'level', 'status', 'created_at') + list_filter = ('status', 'level', 'salesperson', 'distributor', 'created_at') + search_fields = ('salesperson__name', 'distributor__user__nickname', 'distributor__user__phone_number', 'order__id') + readonly_fields = ('amount', 'level', 'created_at') + + fieldsets = ( + ('基本信息', { + 'fields': ('salesperson', 'distributor', 'order', 'amount', 'level') + }), + ('状态管理', { + 'fields': ('status', 'created_at') + }), + ) + +class GenderFilter(admin.SimpleListFilter): + title = '性别' + parameter_name = 'gender' + + def lookups(self, request, model_admin): + return ( + (1, '男'), + (2, '女'), + (0, '未知'), + ) + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter(gender=self.value()) + return queryset + +class UserSourceFilter(admin.SimpleListFilter): + title = '用户来源' + parameter_name = 'user_source' + + def lookups(self, request, model_admin): + return ( + ('miniprogram', '仅小程序用户'), + ('both', '网页小程序都已注册'), + ) + + def queryset(self, request, queryset): + if self.value() == 'miniprogram': + return queryset.filter(user__isnull=True) + if self.value() == 'both': + return queryset.filter(user__isnull=False) + return queryset + +class PriceRangeFilter(admin.SimpleListFilter): + title = '价格区间' + parameter_name = 'price_range' + + def lookups(self, request, model_admin): + return ( + ('0-50', '¥0 - ¥50'), + ('50-100', '¥50 - ¥100'), + ('100-500', '¥100 - ¥500'), + ('500-1000', '¥500 - ¥1000'), + ('1000+', '¥1000以上'), + ) + + def queryset(self, request, queryset): + value = self.value() + if value == '0-50': + return queryset.filter(total_price__gte=0, total_price__lte=50) + elif value == '50-100': + return queryset.filter(total_price__gt=50, total_price__lte=100) + elif value == '100-500': + return queryset.filter(total_price__gt=100, total_price__lte=500) + elif value == '500-1000': + return queryset.filter(total_price__gt=500, total_price__lte=1000) + elif value == '1000+': + return queryset.filter(total_price__gt=1000) + return queryset + +class ProductTypeFilter(admin.SimpleListFilter): + title = '商品类型' + parameter_name = 'product_type' + + def lookups(self, request, model_admin): + return ( + ('hardware', '硬件产品'), + ('course', '课程'), + ('activity', '活动'), + ) + + def queryset(self, request, queryset): + value = self.value() + if value == 'hardware': + return queryset.filter(config__isnull=False) + elif value == 'course': + return queryset.filter(course__isnull=False) + elif value == 'activity': + return queryset.filter(activity__isnull=False) + return queryset + +@admin.register(Order) +class OrderAdmin(ModelAdmin): + list_display = ('id', 'customer_name', 'get_item_name', 'total_price', 'status', 'salesperson', 'distributor', 'created_at') + list_filter = ('status', ProductTypeFilter, 'config', 'course', 'activity', PriceRangeFilter, 'salesperson', 'distributor', 'created_at') + search_fields = ('id', 'customer_name', 'phone_number', 'wechat_trade_no', 'wechat_user__phone_number') + readonly_fields = ('total_price', 'created_at', 'wechat_trade_no') + actions = [export_to_csv, export_to_excel] + + def get_item_name(self, obj): + if obj.config: + return f"[硬件] {obj.config.name}" + if obj.course: + return f"[课程] {obj.course.title}" + if obj.activity: + return f"[活动] {obj.activity.title}" + return "未知商品" + get_item_name.short_description = "购买商品" + + fieldsets = ( + ('订单信息', { + 'fields': ('config', 'course', 'activity', 'quantity', 'total_price', 'status', 'created_at') + }), + ('客户信息', { + 'fields': ('customer_name', 'phone_number', 'shipping_address', 'wechat_user') + }), + ('物流信息', { + 'fields': ('courier_name', 'tracking_number') + }), + ('销售归属', { + 'fields': ('salesperson', 'distributor') + }), + ('支付信息', { + 'fields': ('wechat_trade_no',) + }), + ) + +@admin.register(WeChatUser) +class WeChatUserAdmin(OrderableAdminMixin, ModelAdmin): + list_display = ('nickname', 'phone_number', 'is_star', 'title', 'avatar_display', 'gender_display', 'province', 'city', 'created_at', 'order_actions') + search_fields = ('nickname', 'openid', 'phone_number') + list_filter = ('is_star', GenderFilter, UserSourceFilter, 'province', 'city', 'created_at') + readonly_fields = ('openid', 'unionid', 'session_key', 'created_at', 'updated_at') + actions = [export_to_csv, export_to_excel] + + def avatar_display(self, obj): + if obj.avatar_url: + return format_html('', obj.avatar_url) + return "暂无" + avatar_display.short_description = "头像" + + def gender_display(self, obj): + choices = {0: '未知', 1: '男', 2: '女'} + return choices.get(obj.gender, '未知') + gender_display.short_description = "性别" + + def get_fieldsets(self, request, obj=None): + fieldsets = [ + ('基本信息', { + 'fields': ('user', 'nickname', 'phone_number', 'avatar_url', 'gender') + }), + ] + + if obj and obj.is_star: + fieldsets.append(('专家认证', { + 'fields': ('is_star', 'title', 'skills', 'order'), + 'description': '标记为明星技术用户/专家,将在社区中展示' + })) + else: + fieldsets.append(('专家认证', { + 'fields': ('is_star',), + 'description': '标记为明星技术用户/专家,将在社区中展示。保存后若为专家用户,可进一步编辑专家信息。' + })) + + fieldsets.append(('位置信息', { + 'fields': ('country', 'province', 'city') + })) + + fieldsets.append(('认证信息', { + 'fields': ('openid', 'unionid', 'session_key'), + 'classes': ('collapse',) + })) + + fieldsets.append(('时间信息', { + 'fields': ('created_at', 'updated_at') + })) + + return fieldsets + +# 小程序分销员已隐藏 - 取消注册 +# @admin.register(Distributor) +class DistributorAdmin(ModelAdmin): + pass + +# 提现管理已隐藏 +# @admin.register(Withdrawal) +class WithdrawalAdmin(ModelAdmin): + pass + +@admin.register(AdminPhoneNumber) +class AdminPhoneNumberAdmin(ModelAdmin): + list_display = ('name', 'phone_number', 'is_active', 'created_at') + list_filter = ('is_active',) + search_fields = ('name', 'phone_number') + + +class UserIdentityInline(TabularInline): + model = UserIdentity + extra = 1 + autocomplete_fields = ['tag'] + + +@admin.register(IdentityTag) +class IdentityTagAdmin(ModelAdmin): + list_display = ('name', 'color_preview', 'icon', 'sort_order', 'is_active', 'created_at') + list_editable = ['sort_order', 'is_active'] + list_filter = ('is_active', 'created_at') + search_fields = ('name', 'description') + + @display(description='颜色预览') + def color_preview(self, obj): + return format_html( + ' {}', + obj.color, obj.color + ) + + +@admin.register(UserIdentity) +class UserIdentityAdmin(ModelAdmin): + list_display = ('user_info', 'tag', 'assigned_at', 'assigned_by') + list_filter = ('tag', 'assigned_at') + search_fields = ('user__nickname', 'user__phone_number', 'user__openid', 'tag__name') + autocomplete_fields = ['user', 'tag'] + date_hierarchy = 'assigned_at' + + @display(description='用户信息') + def user_info(self, obj): + return f"{obj.user.nickname or ''} {obj.user.phone_number or ''}".strip() or obj.user.openid[:20] diff --git a/backend/shop/admin_actions.py b/backend/shop/admin_actions.py new file mode 100644 index 0000000..59bff16 --- /dev/null +++ b/backend/shop/admin_actions.py @@ -0,0 +1,110 @@ +import csv +import datetime +from django.http import HttpResponse +from django.utils.encoding import escape_uri_path + +def export_to_csv(modeladmin, request, queryset): + """ + 通用导出 CSV 的 Admin Action + 支持中文编码(UTF-8 BOM),可直接用 Excel 打开 + """ + opts = modeladmin.model._meta + # 设置文件名,使用模型的 verbose_name + filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + response = HttpResponse(content_type='text/csv; charset=utf-8-sig') + response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}' + + writer = csv.writer(response) + + # 获取所有非多对多字段和非反向关联字段 + fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many] + + # 写入表头 (使用字段的 verbose_name) + writer.writerow([field.verbose_name for field in fields]) + + # 写入数据 + for obj in queryset: + data_row = [] + for field in fields: + value = getattr(obj, field.name) + + # 处理 Choice 字段,显示可读的标签 + if hasattr(obj, f'get_{field.name}_display'): + value = getattr(obj, f'get_{field.name}_display')() + + # 处理关联对象(ForeignKey) + if field.is_relation and value: + value = str(value) + + # 处理日期时间 + if isinstance(value, datetime.datetime): + value = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, datetime.date): + value = value.strftime('%Y-%m-%d') + + # 处理 None + if value is None: + value = "" + + data_row.append(str(value)) + writer.writerow(data_row) + + return response + +export_to_csv.short_description = "导出选中项为 CSV" + +def export_to_excel(modeladmin, request, queryset): + """ + 导出为 Excel (需要安装 openpyxl) + """ + try: + from openpyxl import Workbook + except ImportError: + modeladmin.message_user(request, "请先安装 openpyxl 库以使用 Excel 导出功能: pip install openpyxl", level='error') + return + + opts = modeladmin.model._meta + filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + + response = HttpResponse( + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ) + response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}' + + wb = Workbook() + ws = wb.active + # Sheet name limit is 31 chars + ws.title = str(opts.verbose_name)[:31] + + fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many] + + # 写入表头 + ws.append([str(field.verbose_name) for field in fields]) + + # 写入数据 + for obj in queryset: + row = [] + for field in fields: + value = getattr(obj, field.name) + + if hasattr(obj, f'get_{field.name}_display'): + value = getattr(obj, f'get_{field.name}_display')() + + # 处理关联对象(ForeignKey) + if field.is_relation and value: + value = str(value) + + if isinstance(value, (datetime.datetime, datetime.date)): + # openpyxl 可以直接处理 datetime 格式,Excel 会自动识别 + # 但为了避免时区问题,通常转为无时区时间或字符串 + if isinstance(value, datetime.datetime): + value = value.replace(tzinfo=None) + + row.append(value) + ws.append(row) + + wb.save(response) + return response + +export_to_excel.short_description = "导出选中项为 Excel" diff --git a/backend/shop/apps.py b/backend/shop/apps.py new file mode 100644 index 0000000..476a198 --- /dev/null +++ b/backend/shop/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class ShopConfig(AppConfig): + name = 'shop' + verbose_name = "课程培训" + + def ready(self): + import shop.signals diff --git a/backend/shop/migrations/0001_initial.py b/backend/shop/migrations/0001_initial.py new file mode 100644 index 0000000..5d3d824 --- /dev/null +++ b/backend/shop/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# Generated by Django 6.0.1 on 2026-02-02 04:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ESP32Config', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, verbose_name='配置名称')), + ('chip_type', models.CharField(help_text='例如: ESP32-S3, ESP32-C3', max_length=50, verbose_name='芯片型号')), + ('flash_size', models.IntegerField(default=4, verbose_name='Flash大小(MB)')), + ('ram_size', models.IntegerField(default=2, verbose_name='PSRAM大小(MB)')), + ('has_camera', models.BooleanField(default=False, verbose_name='是否包含摄像头')), + ('has_microphone', models.BooleanField(default=False, verbose_name='是否包含麦克风')), + ('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='价格')), + ('description', models.TextField(blank=True, verbose_name='描述')), + ], + options={ + 'verbose_name': '硬件配置', + 'verbose_name_plural': '硬件配置列表', + }, + ), + migrations.CreateModel( + name='Order', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('quantity', models.IntegerField(default=1, verbose_name='数量')), + ('total_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='总价')), + ('status', models.CharField(choices=[('pending', '待支付'), ('paid', '已支付'), ('shipped', '已发货'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='订单状态')), + ('wechat_trade_no', models.CharField(blank=True, max_length=100, null=True, verbose_name='微信支付单号')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.esp32config', verbose_name='所选配置')), + ], + options={ + 'verbose_name': '订单', + 'verbose_name_plural': '订单列表', + }, + ), + ] diff --git a/backend/shop/migrations/0002_order_customer_name_order_phone_number_and_more.py b/backend/shop/migrations/0002_order_customer_name_order_phone_number_and_more.py new file mode 100644 index 0000000..d8aafc5 --- /dev/null +++ b/backend/shop/migrations/0002_order_customer_name_order_phone_number_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.1 on 2026-02-02 04:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='customer_name', + field=models.CharField(default='', max_length=100, verbose_name='收货人姓名'), + ), + migrations.AddField( + model_name='order', + name='phone_number', + field=models.CharField(default='', max_length=20, verbose_name='联系电话'), + ), + migrations.AddField( + model_name='order', + name='shipping_address', + field=models.TextField(default='', verbose_name='发货地址'), + ), + ] diff --git a/backend/shop/migrations/0003_salesperson_alter_esp32config_options_and_more.py b/backend/shop/migrations/0003_salesperson_alter_esp32config_options_and_more.py new file mode 100644 index 0000000..8968035 --- /dev/null +++ b/backend/shop/migrations/0003_salesperson_alter_esp32config_options_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 6.0.1 on 2026-02-02 04:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0002_order_customer_name_order_phone_number_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Salesperson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='销售员姓名')), + ('code', models.CharField(help_text='唯一的推广标识码,如: zhangsan01', max_length=20, unique=True, verbose_name='推广码')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ], + options={ + 'verbose_name': '销售员', + 'verbose_name_plural': '销售员管理', + }, + ), + migrations.AlterModelOptions( + name='esp32config', + options={'verbose_name': '硬件配置 (小智参数)', 'verbose_name_plural': '硬件配置 (小智参数)'}, + ), + migrations.AddField( + model_name='order', + name='salesperson', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.salesperson', verbose_name='所属销售员'), + ), + ] diff --git a/backend/shop/migrations/0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more.py b/backend/shop/migrations/0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more.py new file mode 100644 index 0000000..5d9f8e3 --- /dev/null +++ b/backend/shop/migrations/0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 5.2.10 on 2026-02-02 04:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0003_salesperson_alter_esp32config_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='WeChatPayConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('app_id', models.CharField(max_length=50, verbose_name='AppID')), + ('mch_id', models.CharField(max_length=50, verbose_name='商户号(MchID)')), + ('api_key', models.CharField(max_length=100, verbose_name='API密钥(Key)')), + ('app_secret', models.CharField(blank=True, max_length=100, null=True, verbose_name='AppSecret')), + ('notify_url', models.URLField(verbose_name='回调通知地址')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ], + options={ + 'verbose_name': '微信支付配置', + 'verbose_name_plural': '微信支付配置', + }, + ), + migrations.AlterField( + model_name='esp32config', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='order', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='salesperson', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/backend/shop/migrations/0005_service_alter_esp32config_id_alter_order_id_and_more.py b/backend/shop/migrations/0005_service_alter_esp32config_id_alter_order_id_and_more.py new file mode 100644 index 0000000..2c70273 --- /dev/null +++ b/backend/shop/migrations/0005_service_alter_esp32config_id_alter_order_id_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 6.0.1 on 2026-02-02 05:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Service', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='服务名称')), + ('icon', models.ImageField(upload_to='services/icons/', verbose_name='图标')), + ('description', models.TextField(verbose_name='简介')), + ('features', models.TextField(help_text='每行一个特性', verbose_name='特性列表')), + ('color', models.CharField(default='#00f0ff', max_length=20, verbose_name='主题色')), + ('detail_image', models.ImageField(blank=True, null=True, upload_to='services/details/', verbose_name='详情页长图')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ], + options={ + 'verbose_name': 'AI服务', + 'verbose_name_plural': 'AI服务管理', + }, + ), + migrations.AlterField( + model_name='esp32config', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='order', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='salesperson', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='wechatpayconfig', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/backend/shop/migrations/0006_arservice_esp32config_detail_image_and_more.py b/backend/shop/migrations/0006_arservice_esp32config_detail_image_and_more.py new file mode 100644 index 0000000..166887b --- /dev/null +++ b/backend/shop/migrations/0006_arservice_esp32config_detail_image_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 6.0.1 on 2026-02-02 05:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0005_service_alter_esp32config_id_alter_order_id_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ARService', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='体验名称')), + ('description', models.TextField(verbose_name='简介')), + ('cover_image', models.ImageField(blank=True, null=True, upload_to='ar/covers/', verbose_name='封面/长图 (上传)')), + ('cover_image_url', models.URLField(blank=True, null=True, verbose_name='封面/长图 (URL)')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ], + options={ + 'verbose_name': 'AR体验', + 'verbose_name_plural': 'AR体验管理', + }, + ), + migrations.AddField( + model_name='esp32config', + name='detail_image', + field=models.ImageField(blank=True, null=True, upload_to='products/details/', verbose_name='详情页长图 (上传)'), + ), + migrations.AddField( + model_name='esp32config', + name='detail_image_url', + field=models.URLField(blank=True, help_text='如果填写了URL,将优先使用URL', null=True, verbose_name='详情页长图 (URL)'), + ), + migrations.AddField( + model_name='service', + name='detail_image_url', + field=models.URLField(blank=True, null=True, verbose_name='详情页长图 (URL)'), + ), + migrations.AddField( + model_name='service', + name='icon_url', + field=models.URLField(blank=True, null=True, verbose_name='图标 (URL)'), + ), + migrations.AlterField( + model_name='service', + name='detail_image', + field=models.ImageField(blank=True, null=True, upload_to='services/details/', verbose_name='详情页长图 (上传)'), + ), + migrations.AlterField( + model_name='service', + name='icon', + field=models.ImageField(blank=True, null=True, upload_to='services/icons/', verbose_name='图标 (上传)'), + ), + ] diff --git a/backend/shop/migrations/0007_productfeature.py b/backend/shop/migrations/0007_productfeature.py new file mode 100644 index 0000000..a8e0c21 --- /dev/null +++ b/backend/shop/migrations/0007_productfeature.py @@ -0,0 +1,32 @@ +# Generated by Django 6.0.1 on 2026-02-02 06:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0006_arservice_esp32config_detail_image_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ProductFeature', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=50, verbose_name='特性标题')), + ('description', models.TextField(verbose_name='特性描述')), + ('icon_name', models.CharField(blank=True, help_text='例如: SafetyCertificate, Eye, Thunderbolt', max_length=50, null=True, verbose_name='Antd图标名称')), + ('icon_image', models.ImageField(blank=True, null=True, upload_to='products/features/', verbose_name='特性图标 (上传)')), + ('icon_url', models.URLField(blank=True, null=True, verbose_name='特性图标 (URL)')), + ('order', models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='features', to='shop.esp32config', verbose_name='所属产品')), + ], + options={ + 'verbose_name': '产品特性', + 'verbose_name_plural': '产品特性', + 'ordering': ['order'], + }, + ), + ] diff --git a/backend/shop/migrations/0008_service_delivery_content_service_delivery_time_and_more.py b/backend/shop/migrations/0008_service_delivery_content_service_delivery_time_and_more.py new file mode 100644 index 0000000..bf9889a --- /dev/null +++ b/backend/shop/migrations/0008_service_delivery_content_service_delivery_time_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 6.0.1 on 2026-02-02 06:16 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0007_productfeature'), + ] + + operations = [ + migrations.AddField( + model_name='service', + name='delivery_content', + field=models.TextField(blank=True, help_text='描述将交付给客户的具体成果', verbose_name='交付内容'), + ), + migrations.AddField( + model_name='service', + name='delivery_time', + field=models.CharField(blank=True, help_text='例如:3-5个工作日', max_length=50, verbose_name='预计交付周期'), + ), + migrations.AddField( + model_name='service', + name='price', + field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='起步价格'), + ), + migrations.AddField( + model_name='service', + name='unit', + field=models.CharField(default='次', help_text='例如:次、小时、月、个', max_length=20, verbose_name='计费单位'), + ), + migrations.CreateModel( + name='ServiceOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('customer_name', models.CharField(max_length=100, verbose_name='客户姓名')), + ('company_name', models.CharField(blank=True, max_length=100, verbose_name='公司名称')), + ('phone_number', models.CharField(max_length=20, verbose_name='联系电话')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='电子邮箱')), + ('requirements', models.TextField(blank=True, verbose_name='具体需求描述')), + ('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='预估总价')), + ('status', models.CharField(choices=[('pending', '待沟通/待支付'), ('processing', '服务进行中'), ('completed', '已完成'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='订单状态')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('salesperson', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.salesperson', verbose_name='所属销售员')), + ('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.service', verbose_name='所选服务')), + ], + options={ + 'verbose_name': '服务订单', + 'verbose_name_plural': '服务订单列表', + }, + ), + ] diff --git a/backend/shop/migrations/0009_esp32config_model_3d_url_and_more.py b/backend/shop/migrations/0009_esp32config_model_3d_url_and_more.py new file mode 100644 index 0000000..f8e6b01 --- /dev/null +++ b/backend/shop/migrations/0009_esp32config_model_3d_url_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-02-02 11:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0008_service_delivery_content_service_delivery_time_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='esp32config', + name='model_3d_url', + field=models.URLField(blank=True, null=True, verbose_name='产品3D模型 (URL)'), + ), + migrations.AddField( + model_name='esp32config', + name='static_image_url', + field=models.URLField(blank=True, null=True, verbose_name='产品静态图 (URL)'), + ), + ] diff --git a/backend/shop/migrations/0010_alter_esp32config_model_3d_url.py b/backend/shop/migrations/0010_alter_esp32config_model_3d_url.py new file mode 100644 index 0000000..e24695f --- /dev/null +++ b/backend/shop/migrations/0010_alter_esp32config_model_3d_url.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-02-02 12:39 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0009_esp32config_model_3d_url_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='esp32config', + name='model_3d_url', + field=models.URLField(blank=True, help_text='请上传包含 .obj 模型文件和 .mtl 材质文件的 .zip 压缩包', null=True, verbose_name='产品3D模型 (URL)'), + ), + ] diff --git a/backend/shop/migrations/0011_alter_esp32config_model_3d_url.py b/backend/shop/migrations/0011_alter_esp32config_model_3d_url.py new file mode 100644 index 0000000..1ffa4cc --- /dev/null +++ b/backend/shop/migrations/0011_alter_esp32config_model_3d_url.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-02-02 14:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0010_alter_esp32config_model_3d_url'), + ] + + operations = [ + migrations.AlterField( + model_name='esp32config', + name='model_3d_url', + field=models.URLField(blank=True, null=True, verbose_name='产品3D模型 (URL)'), + ), + ] diff --git a/backend/shop/migrations/0012_wechatpayconfig_apiv3_key_and_more.py b/backend/shop/migrations/0012_wechatpayconfig_apiv3_key_and_more.py new file mode 100644 index 0000000..f7cf345 --- /dev/null +++ b/backend/shop/migrations/0012_wechatpayconfig_apiv3_key_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.1 on 2026-02-06 13:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0011_alter_esp32config_model_3d_url'), + ] + + operations = [ + migrations.AddField( + model_name='wechatpayconfig', + name='apiv3_key', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='API V3密钥'), + ), + migrations.AddField( + model_name='wechatpayconfig', + name='mch_cert_serial_no', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='商户证书序列号'), + ), + migrations.AddField( + model_name='wechatpayconfig', + name='mch_private_key', + field=models.TextField(blank=True, help_text='apiclient_key.pem 的内容', null=True, verbose_name='商户私钥内容'), + ), + migrations.AlterField( + model_name='wechatpayconfig', + name='api_key', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='API密钥(V2 Key)'), + ), + ] diff --git a/backend/shop/migrations/0013_order_out_trade_no.py b/backend/shop/migrations/0013_order_out_trade_no.py new file mode 100644 index 0000000..411e632 --- /dev/null +++ b/backend/shop/migrations/0013_order_out_trade_no.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-02-07 09:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0012_wechatpayconfig_apiv3_key_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='out_trade_no', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='商户订单号'), + ), + ] diff --git a/backend/shop/migrations/0014_esp32config_stock_order_courier_name_and_more.py b/backend/shop/migrations/0014_esp32config_stock_order_courier_name_and_more.py new file mode 100644 index 0000000..b7cb7d2 --- /dev/null +++ b/backend/shop/migrations/0014_esp32config_stock_order_courier_name_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.1 on 2026-02-10 15:16 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0013_order_out_trade_no'), + ] + + operations = [ + migrations.AddField( + model_name='esp32config', + name='stock', + field=models.IntegerField(default=0, verbose_name='库存数量'), + ), + migrations.AddField( + model_name='order', + name='courier_name', + field=models.CharField(blank=True, max_length=50, null=True, verbose_name='快递公司'), + ), + migrations.AddField( + model_name='order', + name='tracking_number', + field=models.CharField(blank=True, max_length=100, null=True, verbose_name='快递单号'), + ), + ] diff --git a/backend/shop/migrations/0015_esp32config_commission_rate_and_more.py b/backend/shop/migrations/0015_esp32config_commission_rate_and_more.py new file mode 100644 index 0000000..222cc00 --- /dev/null +++ b/backend/shop/migrations/0015_esp32config_commission_rate_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 6.0.1 on 2026-02-10 15:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0014_esp32config_stock_order_courier_name_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='esp32config', + name='commission_rate', + field=models.DecimalField(decimal_places=4, default=0.0, help_text='例如 0.10 表示 10%,优先级高于销售员默认比例', max_digits=5, verbose_name='产品分润比例'), + ), + migrations.AddField( + model_name='salesperson', + name='commission_rate', + field=models.DecimalField(decimal_places=4, default=0.1, help_text='例如 0.10 表示 10%', max_digits=5, verbose_name='默认分润比例'), + ), + migrations.AddField( + model_name='salesperson', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='shop.salesperson', verbose_name='上级分销员'), + ), + migrations.AddField( + model_name='salesperson', + name='second_level_rate', + field=models.DecimalField(decimal_places=4, default=0.02, help_text='作为上级时可获得的分润比例,例如 0.02 表示 2%', max_digits=5, verbose_name='二级分销比例'), + ), + migrations.CreateModel( + name='CommissionLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='佣金金额')), + ('level', models.IntegerField(default=1, help_text='1: 直接销售, 2: 二级分销', verbose_name='分销层级')), + ('status', models.CharField(choices=[('pending', '待结算'), ('settled', '已结算'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='状态')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='shop.order', verbose_name='关联订单')), + ('salesperson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='shop.salesperson', verbose_name='获佣销售员')), + ], + options={ + 'verbose_name': '佣金记录', + 'verbose_name_plural': '佣金结算', + }, + ), + ] diff --git a/backend/shop/migrations/0016_wechatuser_distributor_order_wechat_user.py b/backend/shop/migrations/0016_wechatuser_distributor_order_wechat_user.py new file mode 100644 index 0000000..4537037 --- /dev/null +++ b/backend/shop/migrations/0016_wechatuser_distributor_order_wechat_user.py @@ -0,0 +1,64 @@ +# Generated by Django 6.0.1 on 2026-02-10 16:35 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0015_esp32config_commission_rate_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='WeChatUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('openid', models.CharField(max_length=64, unique=True, verbose_name='OpenID')), + ('unionid', models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='UnionID')), + ('session_key', models.CharField(blank=True, max_length=64, verbose_name='SessionKey')), + ('nickname', models.CharField(blank=True, max_length=64, verbose_name='昵称')), + ('avatar_url', models.URLField(blank=True, verbose_name='头像URL')), + ('gender', models.IntegerField(default=0, help_text='0:未知, 1:男, 2:女', verbose_name='性别')), + ('country', models.CharField(blank=True, max_length=64, verbose_name='国家')), + ('province', models.CharField(blank=True, max_length=64, verbose_name='省份')), + ('city', models.CharField(blank=True, max_length=64, verbose_name='城市')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='wechat_profile', to=settings.AUTH_USER_MODEL, verbose_name='关联系统用户')), + ], + options={ + 'verbose_name': '微信用户', + 'verbose_name_plural': '微信用户管理', + }, + ), + migrations.CreateModel( + name='Distributor', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('level', models.IntegerField(default=1, verbose_name='分销等级')), + ('commission_rate', models.DecimalField(decimal_places=4, default=0.1, help_text='例如 0.10 表示 10%', max_digits=5, verbose_name='分佣比例')), + ('total_earnings', models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='累计收益')), + ('withdrawable_balance', models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='可提现余额')), + ('status', models.CharField(choices=[('pending', '审核中'), ('active', '正常'), ('disabled', '已禁用')], default='pending', max_length=20, verbose_name='状态')), + ('invite_code', models.CharField(blank=True, max_length=20, unique=True, verbose_name='邀请码')), + ('qr_code_url', models.URLField(blank=True, verbose_name='推广二维码URL')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='shop.distributor', verbose_name='上级分销员')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='distributor', to='shop.wechatuser', verbose_name='关联微信用户')), + ], + options={ + 'verbose_name': '分销员', + 'verbose_name_plural': '分销员管理', + }, + ), + migrations.AddField( + model_name='order', + name='wechat_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.wechatuser', verbose_name='下单微信用户'), + ), + ] diff --git a/backend/shop/migrations/0017_withdrawal.py b/backend/shop/migrations/0017_withdrawal.py new file mode 100644 index 0000000..2e9688f --- /dev/null +++ b/backend/shop/migrations/0017_withdrawal.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.1 on 2026-02-10 16:35 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0016_wechatuser_distributor_order_wechat_user'), + ] + + operations = [ + migrations.CreateModel( + name='Withdrawal', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='提现金额')), + ('status', models.CharField(choices=[('pending', '审核中'), ('approved', '已打款'), ('rejected', '已拒绝')], default='pending', max_length=20, verbose_name='状态')), + ('remark', models.TextField(blank=True, verbose_name='备注')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('distributor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='withdrawals', to='shop.distributor', verbose_name='分销员')), + ], + options={ + 'verbose_name': '提现记录', + 'verbose_name_plural': '提现管理', + }, + ), + ] diff --git a/backend/shop/migrations/0018_vbcourse_delete_arservice.py b/backend/shop/migrations/0018_vbcourse_delete_arservice.py new file mode 100644 index 0000000..4975f0f --- /dev/null +++ b/backend/shop/migrations/0018_vbcourse_delete_arservice.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.1 on 2026-02-10 18:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0017_withdrawal'), + ] + + operations = [ + migrations.CreateModel( + name='VBCourse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='课程名称')), + ('description', models.TextField(verbose_name='课程简介')), + ('course_type', models.CharField(choices=[('software', '软件课程'), ('hardware', '硬件课程')], default='software', max_length=20, verbose_name='课程类型')), + ('duration', models.CharField(default='30分钟', help_text='例如: 30分钟', max_length=50, verbose_name='课程时长')), + ('lesson_count', models.IntegerField(default=1, verbose_name='课时数量')), + ('instructor', models.CharField(default='VB讲师', max_length=50, verbose_name='讲师')), + ('cover_image', models.ImageField(blank=True, null=True, upload_to='courses/covers/', verbose_name='封面图 (上传)')), + ('cover_image_url', models.URLField(blank=True, null=True, verbose_name='封面图 (URL)')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ], + options={ + 'verbose_name': 'VB课程', + 'verbose_name_plural': 'VB课程管理', + }, + ), + migrations.DeleteModel( + name='ARService', + ), + ] diff --git a/backend/shop/migrations/0019_vbcourse_detail_image_vbcourse_detail_image_url_and_more.py b/backend/shop/migrations/0019_vbcourse_detail_image_vbcourse_detail_image_url_and_more.py new file mode 100644 index 0000000..8a4e192 --- /dev/null +++ b/backend/shop/migrations/0019_vbcourse_detail_image_vbcourse_detail_image_url_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.1 on 2026-02-10 18:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0018_vbcourse_delete_arservice'), + ] + + operations = [ + migrations.AddField( + model_name='vbcourse', + name='detail_image', + field=models.ImageField(blank=True, null=True, upload_to='courses/details/', verbose_name='详情页长图 (上传)'), + ), + migrations.AddField( + model_name='vbcourse', + name='detail_image_url', + field=models.URLField(blank=True, help_text='如果填写了URL,将优先使用URL', null=True, verbose_name='详情页长图 (URL)'), + ), + migrations.AddField( + model_name='vbcourse', + name='tag', + field=models.CharField(blank=True, help_text='例如: 热门, 推荐, 进阶', max_length=20, verbose_name='标签'), + ), + ] diff --git a/backend/shop/migrations/0020_alter_vbcourse_course_type.py b/backend/shop/migrations/0020_alter_vbcourse_course_type.py new file mode 100644 index 0000000..857e060 --- /dev/null +++ b/backend/shop/migrations/0020_alter_vbcourse_course_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-02-10 18:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0019_vbcourse_detail_image_vbcourse_detail_image_url_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='vbcourse', + name='course_type', + field=models.CharField(choices=[('software', '软件课程'), ('hardware', '硬件课程'), ('incubation', '产品商业孵化')], default='software', max_length=20, verbose_name='课程类型'), + ), + ] diff --git a/backend/shop/migrations/0021_commissionlog_distributor_order_distributor_and_more.py b/backend/shop/migrations/0021_commissionlog_distributor_order_distributor_and_more.py new file mode 100644 index 0000000..c452b7a --- /dev/null +++ b/backend/shop/migrations/0021_commissionlog_distributor_order_distributor_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0.1 on 2026-02-10 19:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0020_alter_vbcourse_course_type'), + ] + + operations = [ + migrations.AddField( + model_name='commissionlog', + name='distributor', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='shop.distributor', verbose_name='获佣分销员'), + ), + migrations.AddField( + model_name='order', + name='distributor', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.distributor', verbose_name='所属分销员'), + ), + migrations.AlterField( + model_name='commissionlog', + name='salesperson', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='shop.salesperson', verbose_name='获佣销售员'), + ), + ] diff --git a/backend/shop/migrations/0022_vbcourse_content_vbcourse_price_courseenrollment.py b/backend/shop/migrations/0022_vbcourse_content_vbcourse_price_courseenrollment.py new file mode 100644 index 0000000..0c3faab --- /dev/null +++ b/backend/shop/migrations/0022_vbcourse_content_vbcourse_price_courseenrollment.py @@ -0,0 +1,45 @@ +# Generated by Django 6.0.1 on 2026-02-10 19:20 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0021_commissionlog_distributor_order_distributor_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='vbcourse', + name='content', + field=models.TextField(blank=True, help_text='支持Markdown或HTML', verbose_name='详细内容'), + ), + migrations.AddField( + model_name='vbcourse', + name='price', + field=models.DecimalField(decimal_places=2, default=0, help_text='0表示免费', max_digits=10, verbose_name='价格'), + ), + migrations.CreateModel( + name='CourseEnrollment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('customer_name', models.CharField(max_length=100, verbose_name='姓名')), + ('phone_number', models.CharField(max_length=20, verbose_name='联系电话')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='电子邮箱')), + ('wechat_id', models.CharField(blank=True, max_length=50, verbose_name='微信号')), + ('message', models.TextField(blank=True, verbose_name='留言/备注')), + ('status', models.CharField(choices=[('pending', '待联系'), ('contacted', '已联系'), ('completed', '已完成'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='状态')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='提交时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='shop.vbcourse', verbose_name='咨询课程')), + ('distributor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.distributor', verbose_name='所属分销员')), + ('salesperson', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.salesperson', verbose_name='所属销售员')), + ], + options={ + 'verbose_name': '课程报名', + 'verbose_name_plural': '课程报名管理', + }, + ), + ] diff --git a/backend/shop/migrations/0023_order_course_alter_order_config.py b/backend/shop/migrations/0023_order_course_alter_order_config.py new file mode 100644 index 0000000..48ff8a3 --- /dev/null +++ b/backend/shop/migrations/0023_order_course_alter_order_config.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.1 on 2026-02-10 19:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0022_vbcourse_content_vbcourse_price_courseenrollment'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='course', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.vbcourse', verbose_name='所选课程'), + ), + migrations.AlterField( + model_name='order', + name='config', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='shop.esp32config', verbose_name='所选配置'), + ), + ] diff --git a/backend/shop/migrations/0024_vbcourse_instructor_avatar_and_more.py b/backend/shop/migrations/0024_vbcourse_instructor_avatar_and_more.py new file mode 100644 index 0000000..1be9c01 --- /dev/null +++ b/backend/shop/migrations/0024_vbcourse_instructor_avatar_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.1 on 2026-02-10 19:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0023_order_course_alter_order_config'), + ] + + operations = [ + migrations.AddField( + model_name='vbcourse', + name='instructor_avatar', + field=models.ImageField(blank=True, null=True, upload_to='instructors/avatars/', verbose_name='讲师头像 (上传)'), + ), + migrations.AddField( + model_name='vbcourse', + name='instructor_avatar_url', + field=models.URLField(blank=True, null=True, verbose_name='讲师头像 (URL)'), + ), + migrations.AddField( + model_name='vbcourse', + name='instructor_desc', + field=models.TextField(blank=True, default='拥有多年开发经验,擅长...', verbose_name='讲师简介'), + ), + migrations.AddField( + model_name='vbcourse', + name='instructor_title', + field=models.CharField(default='资深讲师', max_length=50, verbose_name='讲师头衔'), + ), + ] diff --git a/backend/shop/migrations/0025_vccourse_alter_courseenrollment_course_and_more.py b/backend/shop/migrations/0025_vccourse_alter_courseenrollment_course_and_more.py new file mode 100644 index 0000000..9188f37 --- /dev/null +++ b/backend/shop/migrations/0025_vccourse_alter_courseenrollment_course_and_more.py @@ -0,0 +1,55 @@ +# Generated by Django 6.0.1 on 2026-02-10 19:44 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0024_vbcourse_instructor_avatar_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='VCCourse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=100, verbose_name='课程名称')), + ('description', models.TextField(verbose_name='课程简介')), + ('course_type', models.CharField(choices=[('software', '软件课程'), ('hardware', '硬件课程'), ('incubation', '产品商业孵化')], default='software', max_length=20, verbose_name='课程类型')), + ('duration', models.CharField(default='30分钟', help_text='例如: 30分钟', max_length=50, verbose_name='课程时长')), + ('lesson_count', models.IntegerField(default=1, verbose_name='课时数量')), + ('instructor', models.CharField(default='VC讲师', max_length=50, verbose_name='讲师')), + ('instructor_title', models.CharField(default='资深讲师', max_length=50, verbose_name='讲师头衔')), + ('instructor_avatar', models.ImageField(blank=True, null=True, upload_to='instructors/avatars/', verbose_name='讲师头像 (上传)')), + ('instructor_avatar_url', models.URLField(blank=True, null=True, verbose_name='讲师头像 (URL)')), + ('instructor_desc', models.TextField(blank=True, default='拥有多年开发经验,擅长...', verbose_name='讲师简介')), + ('tag', models.CharField(blank=True, help_text='例如: 热门, 推荐, 进阶', max_length=20, verbose_name='标签')), + ('price', models.DecimalField(decimal_places=2, default=0, help_text='0表示免费', max_digits=10, verbose_name='价格')), + ('content', models.TextField(blank=True, help_text='支持Markdown或HTML', verbose_name='详细内容')), + ('cover_image', models.ImageField(blank=True, null=True, upload_to='courses/covers/', verbose_name='封面图 (上传)')), + ('cover_image_url', models.URLField(blank=True, null=True, verbose_name='封面图 (URL)')), + ('detail_image', models.ImageField(blank=True, null=True, upload_to='courses/details/', verbose_name='详情页长图 (上传)')), + ('detail_image_url', models.URLField(blank=True, help_text='如果填写了URL,将优先使用URL', null=True, verbose_name='详情页长图 (URL)')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ], + options={ + 'verbose_name': 'VC课程', + 'verbose_name_plural': 'VC课程管理', + }, + ), + migrations.AlterField( + model_name='courseenrollment', + name='course', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='shop.vccourse', verbose_name='咨询课程'), + ), + migrations.AlterField( + model_name='order', + name='course', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.vccourse', verbose_name='所选课程'), + ), + migrations.DeleteModel( + name='VBCourse', + ), + ] diff --git a/backend/shop/migrations/0026_wechatuser_phone_number.py b/backend/shop/migrations/0026_wechatuser_phone_number.py new file mode 100644 index 0000000..61c557c --- /dev/null +++ b/backend/shop/migrations/0026_wechatuser_phone_number.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-02-11 07:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0025_vccourse_alter_courseenrollment_course_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='wechatuser', + name='phone_number', + field=models.CharField(blank=True, max_length=20, null=True, unique=True, verbose_name='手机号'), + ), + ] diff --git a/backend/shop/migrations/0027_wechatuser_is_star_wechatuser_title.py b/backend/shop/migrations/0027_wechatuser_is_star_wechatuser_title.py new file mode 100644 index 0000000..9fdc0ae --- /dev/null +++ b/backend/shop/migrations/0027_wechatuser_is_star_wechatuser_title.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-02-12 06:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0026_wechatuser_phone_number'), + ] + + operations = [ + migrations.AddField( + model_name='wechatuser', + name='is_star', + field=models.BooleanField(default=False, verbose_name='是否明星技术用户'), + ), + migrations.AddField( + model_name='wechatuser', + name='title', + field=models.CharField(blank=True, default='技术专家', max_length=50, verbose_name='专家头衔'), + ), + ] diff --git a/backend/shop/migrations/0028_fix_goodsid_schema.py b/backend/shop/migrations/0028_fix_goodsid_schema.py new file mode 100644 index 0000000..8266116 --- /dev/null +++ b/backend/shop/migrations/0028_fix_goodsid_schema.py @@ -0,0 +1,14 @@ +# Generated by Django 6.0.1 on 2026-02-12 14:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0027_wechatuser_is_star_wechatuser_title'), + ] + + operations = [ + # 空操作 - 此迁移仅用于旧数据库修复,新数据库无需执行 + ] diff --git a/backend/shop/migrations/0029_fix_legacy_fields.py b/backend/shop/migrations/0029_fix_legacy_fields.py new file mode 100644 index 0000000..1b3a300 --- /dev/null +++ b/backend/shop/migrations/0029_fix_legacy_fields.py @@ -0,0 +1,14 @@ +# Generated by Django 6.0.1 on 2026-02-12 14:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0028_fix_goodsid_schema'), + ] + + operations = [ + # 空操作 - 此迁移仅用于旧数据库修复,新数据库无需执行 + ] diff --git a/backend/shop/migrations/0030_alter_esp32config_options_alter_service_options_and_more.py b/backend/shop/migrations/0030_alter_esp32config_options_alter_service_options_and_more.py new file mode 100644 index 0000000..9b74e1c --- /dev/null +++ b/backend/shop/migrations/0030_alter_esp32config_options_alter_service_options_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 6.0.1 on 2026-02-13 16:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0029_fix_legacy_fields'), + ] + + operations = [ + migrations.AlterModelOptions( + name='esp32config', + options={'ordering': ['order'], 'verbose_name': '硬件配置 (小智参数)', 'verbose_name_plural': '硬件配置 (小智参数)'}, + ), + migrations.AlterModelOptions( + name='service', + options={'ordering': ['order'], 'verbose_name': 'AI服务', 'verbose_name_plural': 'AI服务管理'}, + ), + migrations.AlterModelOptions( + name='vccourse', + options={'ordering': ['order'], 'verbose_name': 'VC课程', 'verbose_name_plural': 'VC课程管理'}, + ), + migrations.AddField( + model_name='esp32config', + name='order', + field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'), + ), + migrations.AddField( + model_name='service', + name='order', + field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'), + ), + migrations.AddField( + model_name='vccourse', + name='order', + field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'), + ), + migrations.AlterField( + model_name='order', + name='config', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='shop.esp32config', verbose_name='所选配置'), + ), + ] diff --git a/backend/shop/migrations/0031_adminphonenumber.py b/backend/shop/migrations/0031_adminphonenumber.py new file mode 100644 index 0000000..d36c7ce --- /dev/null +++ b/backend/shop/migrations/0031_adminphonenumber.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.1 on 2026-02-16 11:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0030_alter_esp32config_options_alter_service_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='AdminPhoneNumber', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='管理员姓名')), + ('phone_number', models.CharField(max_length=20, verbose_name='手机号')), + ('is_active', models.BooleanField(default=True, verbose_name='是否接收通知')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ], + options={ + 'verbose_name': '管理员通知手机号', + 'verbose_name_plural': '管理员通知手机号', + }, + ), + ] diff --git a/backend/shop/migrations/0032_order_activity.py b/backend/shop/migrations/0032_order_activity.py new file mode 100644 index 0000000..7861b15 --- /dev/null +++ b/backend/shop/migrations/0032_order_activity.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0.1 on 2026-02-23 07:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('community', '0001_initial'), + ('shop', '0031_adminphonenumber'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='activity', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='community.activity', verbose_name='所选活动'), + ), + ] diff --git a/backend/shop/migrations/0033_vccourse_is_fixed_schedule_vccourse_schedule_time.py b/backend/shop/migrations/0033_vccourse_is_fixed_schedule_vccourse_schedule_time.py new file mode 100644 index 0000000..e529428 --- /dev/null +++ b/backend/shop/migrations/0033_vccourse_is_fixed_schedule_vccourse_schedule_time.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-02-23 15:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0032_order_activity'), + ] + + operations = [ + migrations.AddField( + model_name='vccourse', + name='is_fixed_schedule', + field=models.BooleanField(default=False, help_text='勾选后,前端将显示具体的开课时间', verbose_name='是否固定时间课程'), + ), + migrations.AddField( + model_name='vccourse', + name='schedule_time', + field=models.CharField(blank=True, help_text='例如:每周六晚 20:00', max_length=100, null=True, verbose_name='课程具体时间'), + ), + ] diff --git a/backend/shop/migrations/0034_remove_vccourse_schedule_time_vccourse_end_time_and_more.py b/backend/shop/migrations/0034_remove_vccourse_schedule_time_vccourse_end_time_and_more.py new file mode 100644 index 0000000..c5a9d49 --- /dev/null +++ b/backend/shop/migrations/0034_remove_vccourse_schedule_time_vccourse_end_time_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 6.0.1 on 2026-02-23 16:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0033_vccourse_is_fixed_schedule_vccourse_schedule_time'), + ] + + operations = [ + migrations.RemoveField( + model_name='vccourse', + name='schedule_time', + ), + migrations.AddField( + model_name='vccourse', + name='end_time', + field=models.DateTimeField(blank=True, null=True, verbose_name='结束时间'), + ), + migrations.AddField( + model_name='vccourse', + name='start_time', + field=models.DateTimeField(blank=True, null=True, verbose_name='开始时间'), + ), + ] diff --git a/backend/shop/migrations/0035_wechatuser_skills.py b/backend/shop/migrations/0035_wechatuser_skills.py new file mode 100644 index 0000000..bed7b22 --- /dev/null +++ b/backend/shop/migrations/0035_wechatuser_skills.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-02-24 09:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0034_remove_vccourse_schedule_time_vccourse_end_time_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='wechatuser', + name='skills', + field=models.JSONField(blank=True, default=list, help_text="格式: [{'icon': 'url', 'text': 'React'}, ...]", verbose_name='专家技能'), + ), + ] diff --git a/backend/shop/migrations/0036_alter_wechatuser_options_wechatuser_order.py b/backend/shop/migrations/0036_alter_wechatuser_options_wechatuser_order.py new file mode 100644 index 0000000..0cae35d --- /dev/null +++ b/backend/shop/migrations/0036_alter_wechatuser_options_wechatuser_order.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.1 on 2026-02-24 09:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0035_wechatuser_skills'), + ] + + operations = [ + migrations.AlterModelOptions( + name='wechatuser', + options={'ordering': ['order', '-created_at'], 'verbose_name': '微信用户', 'verbose_name_plural': '微信用户管理'}, + ), + migrations.AddField( + model_name='wechatuser', + name='order', + field=models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重'), + ), + ] diff --git a/backend/shop/migrations/0037_wechatuser_has_web_badge.py b/backend/shop/migrations/0037_wechatuser_has_web_badge.py new file mode 100644 index 0000000..48c5f98 --- /dev/null +++ b/backend/shop/migrations/0037_wechatuser_has_web_badge.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-02-26 10:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0036_alter_wechatuser_options_wechatuser_order'), + ] + + operations = [ + migrations.AddField( + model_name='wechatuser', + name='has_web_badge', + field=models.BooleanField(default=False, verbose_name='是否拥有Web徽章'), + ), + ] diff --git a/backend/shop/migrations/0038_vccourse_is_video_course_vccourse_video_url.py b/backend/shop/migrations/0038_vccourse_is_video_course_vccourse_video_url.py new file mode 100644 index 0000000..f08bbee --- /dev/null +++ b/backend/shop/migrations/0038_vccourse_is_video_course_vccourse_video_url.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-02-27 05:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0037_wechatuser_has_web_badge'), + ] + + operations = [ + migrations.AddField( + model_name='vccourse', + name='is_video_course', + field=models.BooleanField(default=False, verbose_name='是否视频课程'), + ), + migrations.AddField( + model_name='vccourse', + name='video_url', + field=models.URLField(blank=True, help_text='仅当用户付费或报名后可见', null=True, verbose_name='视频课程URL'), + ), + ] diff --git a/backend/shop/migrations/0039_vccourse_video_embed_code.py b/backend/shop/migrations/0039_vccourse_video_embed_code.py new file mode 100644 index 0000000..7b83943 --- /dev/null +++ b/backend/shop/migrations/0039_vccourse_video_embed_code.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.1 on 2026-03-01 09:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0038_vccourse_is_video_course_vccourse_video_url'), + ] + + operations = [ + migrations.AddField( + model_name='vccourse', + name='video_embed_code', + field=models.TextField(blank=True, help_text='支持iframe嵌入代码,优先级高于视频URL', null=True, verbose_name='视频嵌入代码'), + ), + ] diff --git a/backend/shop/migrations/0040_identitytag_alter_courseenrollment_options_and_more.py b/backend/shop/migrations/0040_identitytag_alter_courseenrollment_options_and_more.py new file mode 100644 index 0000000..2831670 --- /dev/null +++ b/backend/shop/migrations/0040_identitytag_alter_courseenrollment_options_and_more.py @@ -0,0 +1,128 @@ +# Generated by Django 4.2.29 on 2026-03-18 12:00 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('shop', '0039_vccourse_video_embed_code'), + ] + + operations = [ + migrations.CreateModel( + name='IdentityTag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True, verbose_name='标签名称')), + ('description', models.TextField(blank=True, verbose_name='标签描述')), + ('color', models.CharField(default='#3B82F6', help_text='十六进制颜色代码,如 #3B82F6', max_length=7, verbose_name='标签颜色')), + ('icon', models.CharField(blank=True, help_text='Material图标名称', max_length=50, verbose_name='图标名称')), + ('sort_order', models.IntegerField(default=0, verbose_name='排序')), + ('is_active', models.BooleanField(default=True, verbose_name='是否启用')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '身份标签', + 'verbose_name_plural': '身份标签管理', + 'ordering': ['sort_order', '-created_at'], + }, + ), + migrations.AlterModelOptions( + name='courseenrollment', + options={'verbose_name': '课程报名', 'verbose_name_plural': '课程报名'}, + ), + migrations.AlterModelOptions( + name='vccourse', + options={'ordering': ['order'], 'verbose_name': '课程', 'verbose_name_plural': '课程管理'}, + ), + migrations.AlterField( + model_name='adminphonenumber', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='commissionlog', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='courseenrollment', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='distributor', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='esp32config', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='order', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='productfeature', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='salesperson', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='service', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='serviceorder', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='vccourse', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='wechatpayconfig', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='wechatuser', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.AlterField( + model_name='withdrawal', + name='id', + field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + migrations.CreateModel( + name='UserIdentity', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('assigned_at', models.DateTimeField(auto_now_add=True, verbose_name='分配时间')), + ('assigned_by', models.CharField(blank=True, max_length=100, verbose_name='分配人')), + ('notes', models.TextField(blank=True, verbose_name='备注')), + ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='users', to='shop.identitytag', verbose_name='身份标签')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='identities', to='shop.wechatuser', verbose_name='微信用户')), + ], + options={ + 'verbose_name': '用户身份', + 'verbose_name_plural': '用户身份管理', + 'ordering': ['-assigned_at'], + 'unique_together': {('user', 'tag')}, + }, + ), + ] diff --git a/backend/shop/migrations/__init__.py b/backend/shop/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/shop/models.py b/backend/shop/models.py new file mode 100644 index 0000000..b41e1f1 --- /dev/null +++ b/backend/shop/models.py @@ -0,0 +1,495 @@ +from django.db import models +from django.utils.html import format_html +import qrcode +from io import BytesIO +import base64 +from django.contrib.auth.models import User + +class WeChatUser(models.Model): + """ + 微信小程序用户模型 + """ + user = models.OneToOneField(User, on_delete=models.CASCADE, null=True, blank=True, related_name='wechat_profile', verbose_name="关联系统用户") + openid = models.CharField(max_length=64, unique=True, verbose_name="OpenID") + unionid = models.CharField(max_length=64, blank=True, null=True, verbose_name="UnionID", db_index=True) + session_key = models.CharField(max_length=64, verbose_name="SessionKey", blank=True) + nickname = models.CharField(max_length=64, verbose_name="昵称", blank=True) + phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True, verbose_name="手机号") + avatar_url = models.URLField(verbose_name="头像URL", blank=True) + gender = models.IntegerField(default=0, verbose_name="性别", help_text="0:未知, 1:男, 2:女") + country = models.CharField(max_length=64, verbose_name="国家", blank=True) + province = models.CharField(max_length=64, verbose_name="省份", blank=True) + city = models.CharField(max_length=64, verbose_name="城市", blank=True) + + # 明星技术用户/专家标识 + is_star = models.BooleanField(default=False, verbose_name="是否明星技术用户") + title = models.CharField(max_length=50, default="技术专家", verbose_name="专家头衔", blank=True) + skills = models.JSONField(default=list, verbose_name="专家技能", blank=True, help_text="格式: [{'icon': 'url', 'text': 'React'}, ...]") + order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前") + + # 徽章标识 + has_web_badge = models.BooleanField(default=False, verbose_name="是否拥有Web徽章") + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + def save(self, *args, **kwargs): + is_new = self.pk is None + super().save(*args, **kwargs) + if is_new and self.order == 0: + WeChatUser.objects.filter(pk=self.pk).update(order=self.pk) + self.order = self.pk + + def __str__(self): + return self.phone_number or self.nickname or self.openid + + class Meta: + verbose_name = "微信用户" + verbose_name_plural = "微信用户管理" + ordering = ['order', '-created_at'] + + +class IdentityTag(models.Model): + """ + 身份标签模型 - 用于给用户打身份标签 + """ + name = models.CharField(max_length=50, verbose_name="标签名称", unique=True) + description = models.TextField(blank=True, verbose_name="标签描述") + color = models.CharField(max_length=7, default="#3B82F6", verbose_name="标签颜色", help_text="十六进制颜色代码,如 #3B82F6") + icon = models.CharField(max_length=50, blank=True, verbose_name="图标名称", help_text="Material图标名称") + sort_order = models.IntegerField(default=0, verbose_name="排序") + is_active = models.BooleanField(default=True, verbose_name="是否启用") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + class Meta: + verbose_name = "身份标签" + verbose_name_plural = "身份标签管理" + ordering = ['sort_order', '-created_at'] + + def __str__(self): + return self.name + + +class UserIdentity(models.Model): + """ + 用户身份关联模型 - 记录用户拥有的身份标签 + """ + user = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='identities', verbose_name="微信用户") + tag = models.ForeignKey(IdentityTag, on_delete=models.CASCADE, related_name='users', verbose_name="身份标签") + assigned_at = models.DateTimeField(auto_now_add=True, verbose_name="分配时间") + assigned_by = models.CharField(max_length=100, blank=True, verbose_name="分配人") + notes = models.TextField(blank=True, verbose_name="备注") + + class Meta: + verbose_name = "用户身份" + verbose_name_plural = "用户身份管理" + ordering = ['-assigned_at'] + unique_together = ['user', 'tag'] + + def __str__(self): + return f"{self.user.nickname or self.user.phone_number} - {self.tag.name}" + + +class Distributor(models.Model): + """ + 分销员模型 (替代原 Salesperson 或与其并存,此处为新系统) + """ + STATUS_CHOICES = ( + ('pending', '审核中'), + ('active', '正常'), + ('disabled', '已禁用'), + ) + + user = models.OneToOneField(WeChatUser, on_delete=models.CASCADE, related_name='distributor', verbose_name="关联微信用户") + parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="上级分销员") + level = models.IntegerField(default=1, verbose_name="分销等级") + commission_rate = models.DecimalField(max_digits=5, decimal_places=4, default=0.10, verbose_name="分佣比例", help_text="例如 0.10 表示 10%") + total_earnings = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name="累计收益") + withdrawable_balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00, verbose_name="可提现余额") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态") + invite_code = models.CharField(max_length=20, unique=True, blank=True, verbose_name="邀请码") + qr_code_url = models.URLField(blank=True, verbose_name="推广二维码URL") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="申请时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + def __str__(self): + return f"{self.user.nickname} - {self.get_status_display()}" + + class Meta: + verbose_name = "分销员" + verbose_name_plural = "分销员管理" + + +class Withdrawal(models.Model): + """ + 提现记录 + """ + STATUS_CHOICES = ( + ('pending', '审核中'), + ('approved', '已打款'), + ('rejected', '已拒绝'), + ) + + distributor = models.ForeignKey(Distributor, on_delete=models.CASCADE, related_name='withdrawals', verbose_name="分销员") + amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="提现金额") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态") + remark = models.TextField(blank=True, verbose_name="备注") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="申请时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + def __str__(self): + return f"{self.distributor.user.nickname} - ¥{self.amount}" + + class Meta: + verbose_name = "提现记录" + verbose_name_plural = "提现管理" + +class ESP32Config(models.Model): + """ + ESP32 硬件配置选项模型 + 用于定义可售卖的硬件参数 + """ + name = models.CharField(max_length=100, verbose_name="配置名称") + chip_type = models.CharField(max_length=50, verbose_name="芯片型号", help_text="例如: ESP32-S3, ESP32-C3") + flash_size = models.IntegerField(verbose_name="Flash大小(MB)", default=4) + ram_size = models.IntegerField(verbose_name="PSRAM大小(MB)", default=2) + has_camera = models.BooleanField(default=False, verbose_name="是否包含摄像头") + has_microphone = models.BooleanField(default=False, verbose_name="是否包含麦克风") + stock = models.IntegerField(default=0, verbose_name="库存数量") + price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="价格") + commission_rate = models.DecimalField(max_digits=5, decimal_places=4, default=0.00, verbose_name="产品分润比例", help_text="例如 0.10 表示 10%,优先级高于销售员默认比例") + description = models.TextField(verbose_name="描述", blank=True) + detail_image = models.ImageField(upload_to='products/details/', blank=True, null=True, verbose_name="详情页长图 (上传)") + detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)", help_text="如果填写了URL,将优先使用URL") + static_image_url = models.URLField(blank=True, null=True, verbose_name="产品静态图 (URL)") + model_3d_url = models.URLField(blank=True, null=True, verbose_name="产品3D模型 (URL)") + order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前") + + def __str__(self): + return f"{self.name} - ¥{self.price}" + + class Meta: + verbose_name = "硬件配置 (小智参数)" + verbose_name_plural = "硬件配置 (小智参数)" + ordering = ['order'] + + +class ProductFeature(models.Model): + """ + 产品特性模型 (关联到具体硬件配置) + """ + product = models.ForeignKey(ESP32Config, on_delete=models.CASCADE, related_name='features', verbose_name="所属产品") + title = models.CharField(max_length=50, verbose_name="特性标题") + description = models.TextField(verbose_name="特性描述") + icon_name = models.CharField(max_length=50, blank=True, null=True, verbose_name="Antd图标名称", help_text="例如: SafetyCertificate, Eye, Thunderbolt") + icon_image = models.ImageField(upload_to='products/features/', blank=True, null=True, verbose_name="特性图标 (上传)") + icon_url = models.URLField(blank=True, null=True, verbose_name="特性图标 (URL)") + order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前") + + def __str__(self): + return f"{self.product.name} - {self.title}" + + class Meta: + verbose_name = "产品特性" + verbose_name_plural = "产品特性" + ordering = ['order'] + + +class Salesperson(models.Model): + """ + 销售人员模型 + """ + name = models.CharField(max_length=50, verbose_name="销售员姓名") + code = models.CharField(max_length=20, unique=True, verbose_name="推广码", help_text="唯一的推广标识码,如: zhangsan01") + parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="上级分销员") + + commission_rate = models.DecimalField(max_digits=5, decimal_places=4, default=0.10, verbose_name="默认分润比例", help_text="例如 0.10 表示 10%") + second_level_rate = models.DecimalField(max_digits=5, decimal_places=4, default=0.02, verbose_name="二级分销比例", help_text="作为上级时可获得的分润比例,例如 0.02 表示 2%") + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + + def __str__(self): + return f"{self.name} ({self.code})" + + class Meta: + verbose_name = "销售员" + verbose_name_plural = "销售员管理" + + +class CommissionLog(models.Model): + """ + 佣金结算记录 + """ + STATUS_CHOICES = ( + ('pending', '待结算'), + ('settled', '已结算'), + ('cancelled', '已取消'), + ) + + order = models.ForeignKey('Order', on_delete=models.CASCADE, verbose_name="关联订单", related_name='commissions') + salesperson = models.ForeignKey(Salesperson, on_delete=models.CASCADE, verbose_name="获佣销售员", related_name='commissions', null=True, blank=True) + distributor = models.ForeignKey(Distributor, on_delete=models.CASCADE, verbose_name="获佣分销员", related_name='commissions', null=True, blank=True) + amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="佣金金额") + level = models.IntegerField(default=1, verbose_name="分销层级", help_text="1: 直接销售, 2: 二级分销") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + + class Meta: + verbose_name = "佣金记录" + verbose_name_plural = "佣金结算" + + def __str__(self): + return f"{self.salesperson.name} - ¥{self.amount} ({self.get_status_display()})" + + +class WeChatPayConfig(models.Model): + """ + 微信支付配置模型 + """ + app_id = models.CharField(max_length=50, verbose_name="AppID") + mch_id = models.CharField(max_length=50, verbose_name="商户号(MchID)") + api_key = models.CharField(max_length=100, verbose_name="API密钥(V2 Key)", blank=True, null=True) + apiv3_key = models.CharField(max_length=100, verbose_name="API V3密钥", blank=True, null=True) + mch_cert_serial_no = models.CharField(max_length=100, verbose_name="商户证书序列号", blank=True, null=True) + mch_private_key = models.TextField(verbose_name="商户私钥内容", blank=True, null=True, help_text="apiclient_key.pem 的内容") + app_secret = models.CharField(max_length=100, verbose_name="AppSecret", blank=True, null=True) + notify_url = models.URLField(verbose_name="回调通知地址") + is_active = models.BooleanField(default=True, verbose_name="是否启用") + + class Meta: + verbose_name = "微信支付配置" + verbose_name_plural = "微信支付配置" + + def __str__(self): + return f"微信支付配置 ({'启用' if self.is_active else '禁用'})" + + def save(self, *args, **kwargs): + # 确保只有一个启用的配置 + if self.is_active: + WeChatPayConfig.objects.filter(is_active=True).exclude(id=self.id).update(is_active=False) + super().save(*args, **kwargs) + + +class Order(models.Model): + """ + 订单模型 + 记录用户的购买请求和支付状态 + """ + STATUS_CHOICES = ( + ('pending', '待支付'), + ('paid', '已支付'), + ('shipped', '已发货'), + ('cancelled', '已取消'), + ) + + config = models.ForeignKey(ESP32Config, on_delete=models.CASCADE, verbose_name="所选配置", null=True, blank=True, related_name='orders') + course = models.ForeignKey('VCCourse', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所选课程", related_name='orders') + activity = models.ForeignKey('community.Activity', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所选活动", related_name='orders') + quantity = models.IntegerField(default=1, verbose_name="数量") + total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="总价") + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="订单状态") + + # 销售归属 + salesperson = models.ForeignKey(Salesperson, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属销售员", related_name='orders') + distributor = models.ForeignKey(Distributor, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属分销员", related_name='orders') + + # 关联微信用户 + wechat_user = models.ForeignKey(WeChatUser, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="下单微信用户", related_name='orders') + + # 用户信息 + customer_name = models.CharField(max_length=100, verbose_name="收货人姓名", default="") + phone_number = models.CharField(max_length=20, verbose_name="联系电话", default="") + shipping_address = models.TextField(verbose_name="发货地址", default="") + + # 物流信息 + courier_name = models.CharField(max_length=50, blank=True, null=True, verbose_name="快递公司") + tracking_number = models.CharField(max_length=100, blank=True, null=True, verbose_name="快递单号") + + # 微信支付相关字段 + out_trade_no = models.CharField(max_length=100, blank=True, null=True, verbose_name="商户订单号") + wechat_trade_no = models.CharField(max_length=100, blank=True, null=True, verbose_name="微信支付单号") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + def __str__(self): + return f"Order #{self.id} - {self.customer_name} - {self.status}" + + class Meta: + verbose_name = "订单" + verbose_name_plural = "订单列表" + + +class Service(models.Model): + """ + AI服务项目模型 + """ + title = models.CharField(max_length=100, verbose_name="服务名称") + icon = models.ImageField(upload_to='services/icons/', blank=True, null=True, verbose_name="图标 (上传)") + icon_url = models.URLField(blank=True, null=True, verbose_name="图标 (URL)") + description = models.TextField(verbose_name="简介") + features = models.TextField(verbose_name="特性列表", help_text="每行一个特性") + price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="起步价格") + unit = models.CharField(max_length=20, default="次", verbose_name="计费单位", help_text="例如:次、小时、月、个") + delivery_time = models.CharField(max_length=50, blank=True, verbose_name="预计交付周期", help_text="例如:3-5个工作日") + delivery_content = models.TextField(blank=True, verbose_name="交付内容", help_text="描述将交付给客户的具体成果") + color = models.CharField(max_length=20, default="#00f0ff", verbose_name="主题色") + detail_image = models.ImageField(upload_to='services/details/', blank=True, null=True, verbose_name="详情页长图 (上传)") + detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前") + + def __str__(self): + return self.title + + class Meta: + verbose_name = "AI服务" + verbose_name_plural = "AI服务管理" + ordering = ['order'] + + +class ServiceOrder(models.Model): + """ + AI服务订单模型 + """ + STATUS_CHOICES = ( + ('pending', '待沟通/待支付'), + ('processing', '服务进行中'), + ('completed', '已完成'), + ('cancelled', '已取消'), + ) + + service = models.ForeignKey(Service, on_delete=models.CASCADE, verbose_name="所选服务") + customer_name = models.CharField(max_length=100, verbose_name="客户姓名") + company_name = models.CharField(max_length=100, blank=True, verbose_name="公司名称") + phone_number = models.CharField(max_length=20, verbose_name="联系电话") + email = models.EmailField(blank=True, verbose_name="电子邮箱") + requirements = models.TextField(verbose_name="具体需求描述", blank=True) + + total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="预估总价", default=0) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="订单状态") + + salesperson = models.ForeignKey(Salesperson, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属销售员") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + def __str__(self): + return f"{self.customer_name} - {self.service.title}" + + class Meta: + verbose_name = "服务订单" + verbose_name_plural = "服务订单列表" + + +class VCCourse(models.Model): + """ + VC (VB Coding) 课程模型 + """ + COURSE_TYPE_CHOICES = ( + ('software', '软件课程'), + ('hardware', '硬件课程'), + ('incubation', '产品商业孵化'), + ) + + title = models.CharField(max_length=100, verbose_name="课程名称") + description = models.TextField(verbose_name="课程简介") + course_type = models.CharField(max_length=20, choices=COURSE_TYPE_CHOICES, default='software', verbose_name="课程类型") + duration = models.CharField(max_length=50, verbose_name="课程时长", help_text="例如: 30分钟", default="30分钟") + lesson_count = models.IntegerField(default=1, verbose_name="课时数量") + instructor = models.CharField(max_length=50, verbose_name="讲师", default="VC讲师") + instructor_title = models.CharField(max_length=50, verbose_name="讲师头衔", default="资深讲师") + instructor_avatar = models.ImageField(upload_to='instructors/avatars/', blank=True, null=True, verbose_name="讲师头像 (上传)") + instructor_avatar_url = models.URLField(blank=True, null=True, verbose_name="讲师头像 (URL)") + instructor_desc = models.TextField(blank=True, verbose_name="讲师简介", default="拥有多年开发经验,擅长...") + + tag = models.CharField(max_length=20, blank=True, verbose_name="标签", help_text="例如: 热门, 推荐, 进阶") + + # 视频课程相关 + is_video_course = models.BooleanField(default=False, verbose_name="是否视频课程") + video_url = models.URLField(blank=True, null=True, verbose_name="视频课程URL", help_text="仅当用户付费或报名后可见") + video_embed_code = models.TextField(blank=True, null=True, verbose_name="视频嵌入代码", help_text="支持iframe嵌入代码,优先级高于视频URL") + + # 课程时间安排 + is_fixed_schedule = models.BooleanField(default=False, verbose_name="是否固定时间课程", help_text="勾选后,前端将显示具体的开课时间") + start_time = models.DateTimeField(blank=True, null=True, verbose_name="开始时间") + end_time = models.DateTimeField(blank=True, null=True, verbose_name="结束时间") + + price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="价格", help_text="0表示免费") + content = models.TextField(blank=True, verbose_name="详细内容", help_text="支持Markdown或HTML") + + cover_image = models.ImageField(upload_to='courses/covers/', blank=True, null=True, verbose_name="封面图 (上传)") + cover_image_url = models.URLField(blank=True, null=True, verbose_name="封面图 (URL)") + + detail_image = models.ImageField(upload_to='courses/details/', blank=True, null=True, verbose_name="详情页长图 (上传)") + detail_image_url = models.URLField(blank=True, null=True, verbose_name="详情页长图 (URL)", help_text="如果填写了URL,将优先使用URL") + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + order = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越小越靠前") + + def save(self, *args, **kwargs): + is_new = self.pk is None + super().save(*args, **kwargs) + if is_new and self.order == 0: + VCCourse.objects.filter(pk=self.pk).update(order=self.pk) + self.order = self.pk + + def __str__(self): + return self.title + + class Meta: + verbose_name = "课程" + verbose_name_plural = "课程管理" + ordering = ['order'] + + +class CourseEnrollment(models.Model): + """ + 课程报名/咨询记录 + """ + STATUS_CHOICES = ( + ('pending', '待联系'), + ('contacted', '已联系'), + ('completed', '已完成'), + ('cancelled', '已取消'), + ) + + course = models.ForeignKey(VCCourse, on_delete=models.CASCADE, verbose_name="咨询课程", related_name='enrollments') + customer_name = models.CharField(max_length=100, verbose_name="姓名") + phone_number = models.CharField(max_length=20, verbose_name="联系电话") + email = models.EmailField(blank=True, verbose_name="电子邮箱") + wechat_id = models.CharField(max_length=50, blank=True, verbose_name="微信号") + message = models.TextField(blank=True, verbose_name="留言/备注") + + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', verbose_name="状态") + + # 销售归属 + salesperson = models.ForeignKey(Salesperson, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属销售员") + distributor = models.ForeignKey(Distributor, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="所属分销员") + + created_at = models.DateTimeField(auto_now_add=True, verbose_name="提交时间") + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + def __str__(self): + return f"{self.customer_name} - {self.course.title}" + + class Meta: + verbose_name = "课程报名" + verbose_name_plural = "课程报名" + + +class AdminPhoneNumber(models.Model): + """ + 管理员通知手机号配置 + 用于接收订单支付成功等重要通知 + """ + name = models.CharField(max_length=50, verbose_name="管理员姓名") + phone_number = models.CharField(max_length=20, verbose_name="手机号") + is_active = models.BooleanField(default=True, verbose_name="是否接收通知") + created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") + + def __str__(self): + return f"{self.name} - {self.phone_number}" + + class Meta: + verbose_name = "管理员通知手机号" + verbose_name_plural = "管理员通知手机号" diff --git a/backend/shop/serializers.py b/backend/shop/serializers.py new file mode 100644 index 0000000..988d007 --- /dev/null +++ b/backend/shop/serializers.py @@ -0,0 +1,368 @@ +from rest_framework import serializers +from .models import ESP32Config, Order, Salesperson, Service, VCCourse, ProductFeature, ServiceOrder, WeChatUser, Distributor, Withdrawal, CommissionLog, CourseEnrollment +from .utils import get_current_wechat_user + +class CommissionLogSerializer(serializers.ModelSerializer): + """ + 佣金记录序列化器 + """ + order_info = serializers.SerializerMethodField() + + class Meta: + model = CommissionLog + fields = ['id', 'amount', 'level', 'status', 'created_at', 'order_info'] + read_only_fields = ['id', 'amount', 'level', 'status', 'created_at', 'order_info'] + + def get_order_info(self, obj): + return { + 'order_id': obj.order.id, + 'total_price': obj.order.total_price, + 'customer_name': obj.order.customer_name + } + +class WeChatUserSerializer(serializers.ModelSerializer): + is_admin = serializers.SerializerMethodField() + has_web_account = serializers.SerializerMethodField() + + class Meta: + model = WeChatUser + fields = ['id', 'openid', 'nickname', 'avatar_url', 'gender', 'country', 'province', 'city', 'phone_number', 'is_star', 'title', 'skills', 'is_admin', 'has_web_account', 'has_web_badge'] + read_only_fields = ['id', 'openid', 'phone_number', 'is_star', 'title', 'skills', 'is_admin', 'has_web_account', 'has_web_badge'] + + def get_is_admin(self, obj): + # 检查是否关联了系统用户且具有管理员权限 + return bool(obj.user and obj.user.is_staff) + + def get_has_web_account(self, obj): + # 检查是否关联了系统用户(即网页账号) + return obj.user is not None + +class DistributorSerializer(serializers.ModelSerializer): + user_info = WeChatUserSerializer(source='user', read_only=True) + + class Meta: + model = Distributor + fields = ['id', 'user_info', 'level', 'commission_rate', 'total_earnings', 'withdrawable_balance', 'status', 'invite_code', 'qr_code_url'] + read_only_fields = ['level', 'commission_rate', 'total_earnings', 'withdrawable_balance', 'status', 'invite_code', 'qr_code_url'] + +class WithdrawalSerializer(serializers.ModelSerializer): + class Meta: + model = Withdrawal + fields = ['id', 'amount', 'status', 'remark', 'created_at'] + read_only_fields = ['status', 'created_at', 'remark'] + +class ProductFeatureSerializer(serializers.ModelSerializer): + """ + 产品特性序列化器 + """ + display_icon = serializers.SerializerMethodField() + + class Meta: + model = ProductFeature + fields = ['title', 'description', 'icon_name', 'display_icon', 'order'] + + def get_display_icon(self, obj): + if obj.icon_url: + return obj.icon_url + if obj.icon_image: + return obj.icon_image.url + return None + +class ServiceSerializer(serializers.ModelSerializer): + """ + AI服务序列化器 + """ + features_list = serializers.SerializerMethodField() + display_icon = serializers.SerializerMethodField() + display_detail_image = serializers.SerializerMethodField() + + class Meta: + model = Service + fields = '__all__' + + def get_features_list(self, obj): + if obj.features: + return [line.strip() for line in obj.features.split('\n') if line.strip()] + return [] + + def get_display_icon(self, obj): + if obj.icon_url: + return obj.icon_url + if obj.icon: + return obj.icon.url + return None + + def get_display_detail_image(self, obj): + if obj.detail_image_url: + return obj.detail_image_url + if obj.detail_image: + return obj.detail_image.url + return None + +class CourseEnrollmentSerializer(serializers.ModelSerializer): + """ + 课程报名序列化器 + """ + course_title = serializers.CharField(source='course.title', read_only=True) + ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True) + + class Meta: + model = CourseEnrollment + fields = ['id', 'course', 'course_title', 'customer_name', 'phone_number', 'email', 'wechat_id', 'message', 'status', 'created_at', 'ref_code'] + read_only_fields = ['status', 'created_at'] + + def create(self, validated_data): + ref_code = validated_data.pop('ref_code', None) + + # 尝试关联销售员或分销员 + if ref_code: + try: + salesperson = Salesperson.objects.get(code=ref_code) + validated_data['salesperson'] = salesperson + except Salesperson.DoesNotExist: + pass + + try: + distributor = Distributor.objects.get(invite_code=ref_code) + validated_data['distributor'] = distributor + except Distributor.DoesNotExist: + pass + + return super().create(validated_data) + +class ServiceOrderSerializer(serializers.ModelSerializer): + """ + AI服务订单序列化器 + """ + service_name = serializers.CharField(source='service.title', read_only=True) + # 接收前端传来的 ref_code + ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True) + + class Meta: + model = ServiceOrder + fields = ['id', 'service', 'service_name', 'customer_name', 'company_name', + 'phone_number', 'email', 'requirements', 'total_price', 'status', 'created_at', 'ref_code'] + read_only_fields = ['total_price', 'status', 'created_at'] + + def create(self, validated_data): + ref_code = validated_data.pop('ref_code', None) + service = validated_data.get('service') + + # 默认设置预估总价为服务起步价 + if service: + validated_data['total_price'] = service.price + + # 尝试关联销售员 + if ref_code: + try: + salesperson = Salesperson.objects.get(code=ref_code) + validated_data['salesperson'] = salesperson + except Salesperson.DoesNotExist: + pass + + try: + distributor = Distributor.objects.get(invite_code=ref_code) + validated_data['distributor'] = distributor + except Distributor.DoesNotExist: + pass + + return super().create(validated_data) + +class VCCourseSerializer(serializers.ModelSerializer): + """ + VC课程序列化器 + """ + display_cover_image = serializers.SerializerMethodField() + display_detail_image = serializers.SerializerMethodField() + course_type_display = serializers.CharField(source='get_course_type_display', read_only=True) + video_url = serializers.SerializerMethodField() + video_embed_code = serializers.SerializerMethodField() + is_purchased = serializers.SerializerMethodField() + + class Meta: + model = VCCourse + fields = '__all__' + + def get_display_cover_image(self, obj): + if obj.cover_image_url: + return obj.cover_image_url + if obj.cover_image: + return obj.cover_image.url + return None + + def get_display_detail_image(self, obj): + if obj.detail_image_url: + return obj.detail_image_url + if obj.detail_image: + return obj.detail_image.url + return None + + def _check_purchased(self, obj): + request = self.context.get('request') + if not request: + return False + + # 尝试获取当前用户 + user = get_current_wechat_user(request) + if not user: + return False + + # 如果是管理员,视为已购买 + if user.user and user.user.is_staff: + return True + + # 检查是否已购买/报名 (通过已支付的订单) + has_order = Order.objects.filter( + wechat_user=user, + course=obj, + status__in=['paid', 'shipped', 'completed'] + ).exists() + + return has_order + + def get_is_purchased(self, obj): + return self._check_purchased(obj) + + def get_video_url(self, obj): + """ + 仅当用户已付费/报名时返回视频URL + """ + if not obj.is_video_course: + return None + + if self._check_purchased(obj): + return obj.video_url + + return None + + def get_video_embed_code(self, obj): + """ + 仅当用户已付费/报名时返回视频嵌入代码 + """ + if not obj.is_video_course: + return None + + if self._check_purchased(obj): + return obj.video_embed_code + + return None + +class ESP32ConfigSerializer(serializers.ModelSerializer): + """ + ESP32配置序列化器 + """ + display_detail_image = serializers.SerializerMethodField() + features = ProductFeatureSerializer(many=True, read_only=True) + + class Meta: + model = ESP32Config + fields = '__all__' + + def get_display_detail_image(self, obj): + if obj.detail_image_url: + return obj.detail_image_url + if obj.detail_image: + return obj.detail_image.url + return None + + +class OrderSerializer(serializers.ModelSerializer): + """ + 订单序列化器 + """ + config_name = serializers.CharField(source='config.name', read_only=True) + course_title = serializers.CharField(source='course.title', read_only=True) + activity_title = serializers.CharField(source='activity.title', read_only=True) + config_image = serializers.SerializerMethodField() + salesperson_name = serializers.CharField(source='salesperson.name', read_only=True) + salesperson_code = serializers.CharField(source='salesperson.code', read_only=True) + # 接收前端传来的 ref_code,用于查找 Salesperson + ref_code = serializers.CharField(write_only=True, required=False, allow_blank=True) + + class Meta: + model = Order + fields = ['id', 'config', 'config_name', 'config_image', 'course', 'course_title', 'activity', 'activity_title', 'quantity', 'total_price', 'status', 'created_at', 'updated_at', 'wechat_trade_no', + 'customer_name', 'phone_number', 'shipping_address', 'ref_code', 'salesperson_name', 'salesperson_code', 'courier_name', 'tracking_number'] + read_only_fields = ['total_price', 'status', 'wechat_trade_no', 'created_at', 'updated_at'] + extra_kwargs = { + 'customer_name': {'required': True}, + 'phone_number': {'required': True}, + } + + def validate(self, data): + # 如果是部分更新 (PATCH),可能不需要校验所有字段,但这里主要用于创建 + if self.instance: + return data + + config = data.get('config') + course = data.get('course') + activity = data.get('activity') + + if not config and not course and not activity: + raise serializers.ValidationError("必须选择一种商品(硬件配置、课程或活动)") + + # Count how many types are selected + selected_types = sum([bool(config), bool(course), bool(activity)]) + if selected_types > 1: + raise serializers.ValidationError("一次只能购买一种类型的商品") + + if config and not data.get('shipping_address'): + raise serializers.ValidationError({"shipping_address": "购买硬件产品需要填写收货地址"}) + + return data + + def get_config_image(self, obj): + if obj.config: + if obj.config.static_image_url: + return obj.config.static_image_url + if obj.config.detail_image_url: + return obj.config.detail_image_url + if obj.config.detail_image: + return obj.config.detail_image.url + elif obj.course: + if obj.course.cover_image_url: + return obj.course.cover_image_url + if obj.course.cover_image: + return obj.course.cover_image.url + elif obj.activity: + # Use activity.display_banner_url logic + if obj.activity.banner: + return obj.activity.banner.url + if obj.activity.banner_url: + return obj.activity.banner_url + return None + + def create(self, validated_data): + """ + 重写创建方法,自动计算总价并关联销售员/分销员 + """ + config = validated_data.get('config') + course = validated_data.get('course') + activity = validated_data.get('activity') + quantity = validated_data.get('quantity', 1) + ref_code = validated_data.pop('ref_code', None) + + if config: + validated_data['total_price'] = config.price * quantity + elif course: + validated_data['total_price'] = course.price * quantity + elif activity: + validated_data['total_price'] = activity.price * quantity + + # 尝试关联销售员或分销员 + if ref_code: + # 1. 尝试查找旧版销售员 + try: + salesperson = Salesperson.objects.get(code=ref_code) + validated_data['salesperson'] = salesperson + except Salesperson.DoesNotExist: + pass + + # 2. 尝试查找新版分销员 + try: + distributor = Distributor.objects.get(invite_code=ref_code) + validated_data['distributor'] = distributor + except Distributor.DoesNotExist: + pass + + return super().create(validated_data) diff --git a/backend/shop/services.py b/backend/shop/services.py new file mode 100644 index 0000000..cd68c9b --- /dev/null +++ b/backend/shop/services.py @@ -0,0 +1,177 @@ +import logging +from django.db import models +from .models import Order, CommissionLog, Distributor +# To avoid circular imports, import other models inside function if needed + +logger = logging.getLogger(__name__) + +def handle_post_payment(order): + """ + 处理订单支付成功后的业务逻辑 + 包括: + 1. 更新活动报名状态 + 2. 发送活动报名短信 + 3. 计算分销佣金 + 4. 发送普通订单短信 + """ + print(f"开始处理订单 {order.id} 支付后逻辑...") + + # 1. Handle Activity Signup + if hasattr(order, 'activity') and order.activity: + try: + # Use apps.get_model to avoid circular dependency + from django.apps import apps + ActivitySignup = apps.get_model('community', 'ActivitySignup') + + signup = ActivitySignup.objects.filter(order=order).first() + + # Fallback: try to find by user and activity if not found by order + if not signup and order.wechat_user: + print(f"Warning: ActivitySignup not found by order {order.id}, trying by user/activity") + signup = ActivitySignup.objects.filter( + user=order.wechat_user, + activity=order.activity, + status='unpaid' + ).first() + if signup: + print(f"Found signup {signup.id} by user/activity, linking order...") + signup.order = order + signup.save() + + if signup: + # Determine status based on activity setting + # Use the model method if available, otherwise manual logic + if hasattr(signup, 'check_payment_status'): + signup.check_payment_status() + print(f"活动报名状态已更新(check_payment_status): {signup.id} -> {signup.status}") + else: + new_status = 'confirmed' if signup.activity.auto_confirm else 'pending' + signup.status = new_status + signup.save() + print(f"活动报名状态已更新: {signup.id} -> {new_status}") + + # Send Activity SMS + try: + from .sms_utils import notify_user_activity_signup_success + notify_user_activity_signup_success(order, signup) + except Exception as sms_e: + print(f"发送活动报名短信失败: {str(sms_e)}") + + else: + print(f"Error: No ActivitySignup found for paid order {order.id}") + + except Exception as e: + print(f"更新活动报名状态失败: {str(e)}") + import traceback + traceback.print_exc() + + # 2. 计算佣金 (旧版销售员系统 & 新版分销员系统) + try: + # 旧版销售员系统 + salesperson = order.salesperson + if salesperson: + # 1. 计算直接佣金 (一级) + # 优先级: 产品独立分润比例 > 销售员个人分润比例 + rate_1 = 0 + if order.config: + rate_1 = order.config.commission_rate if order.config.commission_rate > 0 else salesperson.commission_rate + elif order.course: + # 课程暂时使用销售员默认比例 + rate_1 = salesperson.commission_rate + + amount_1 = order.total_price * rate_1 + + if amount_1 > 0: + CommissionLog.objects.create( + order=order, + salesperson=salesperson, + amount=amount_1, + level=1, + status='pending' + ) + print(f"生成一级佣金(Salesperson): {salesperson.name} - {amount_1}") + + # 2. 计算上级佣金 (二级) + parent = salesperson.parent + if parent: + rate_2 = parent.second_level_rate + amount_2 = order.total_price * rate_2 + + if amount_2 > 0: + CommissionLog.objects.create( + order=order, + salesperson=parent, + amount=amount_2, + level=2, + status='pending' + ) + print(f"生成二级佣金(Salesperson): {parent.name} - {amount_2}") + + # 新版分销员系统 + distributor = order.distributor + if distributor: + # 1. 计算直接佣金 (一级) + # 优先级: 产品独立分润比例 > 分销员个人分润比例 + rate_1 = 0 + if order.config: + rate_1 = order.config.commission_rate if order.config.commission_rate > 0 else distributor.commission_rate + elif order.course: + # 课程暂时使用分销员默认比例 + rate_1 = distributor.commission_rate + + amount_1 = order.total_price * rate_1 + + if amount_1 > 0: + CommissionLog.objects.create( + order=order, + distributor=distributor, + amount=amount_1, + level=1, + status='settled' # 简化流程,直接结算到余额 + ) + # 更新余额 + distributor.total_earnings += amount_1 + distributor.withdrawable_balance += amount_1 + distributor.save() + print(f"生成一级佣金(Distributor): {distributor.user.nickname} - {amount_1}") + + # 2. 计算上级佣金 (二级) + parent = distributor.parent + if parent: + # 二级固定比例 2% (0.02) + rate_2 = 0.02 + amount_2 = order.total_price * models.DecimalField(max_digits=5, decimal_places=4).to_python(rate_2) + + if amount_2 > 0: + CommissionLog.objects.create( + order=order, + distributor=parent, + amount=amount_2, + level=2, + status='settled' + ) + # 更新余额 + parent.total_earnings += amount_2 + parent.withdrawable_balance += amount_2 + parent.save() + print(f"生成二级佣金(Distributor): {parent.user.nickname} - {amount_2}") + + except Exception as e: + print(f"佣金计算失败: {str(e)}") + import traceback + traceback.print_exc() + + # 3. 发送普通商品/课程购买的短信通知(排除活动报名,避免重复发送) + # 活动报名的短信已经在上面发送过了 + if not (hasattr(order, 'activity') and order.activity): + try: + from .sms_utils import notify_admins_order_paid, notify_user_order_paid + notify_admins_order_paid(order) + notify_user_order_paid(order) + except Exception as e: + print(f"发送短信通知失败: {str(e)}") + else: + # 额外保险:如果是活动订单,手动标记不触发 signals 中的支付/发货通知 + # 因为 signals 可能会在 save() 时触发 + order._was_paid = False + order._was_shipped = False diff --git a/backend/shop/signals.py b/backend/shop/signals.py new file mode 100644 index 0000000..62bc669 --- /dev/null +++ b/backend/shop/signals.py @@ -0,0 +1,65 @@ +from django.db.models.signals import pre_save, post_save +from django.dispatch import receiver +from .models import Order +from .sms_utils import notify_admins_order_paid, notify_user_order_paid, notify_user_order_shipped + +@receiver(pre_save, sender=Order) +def track_order_changes(sender, instance, **kwargs): + """ + 在保存之前检查状态变化 + """ + if instance.pk: + try: + old_instance = Order.objects.get(pk=instance.pk) + + # 检查是否从非支付状态变为支付状态 + if old_instance.status != 'paid' and instance.status == 'paid': + instance._was_paid = True + + # 检查是否发货 (状态变为 shipped 且有单号) + # 或者已经是 shipped 状态但刚填入单号 + if instance.status == 'shipped' and instance.tracking_number: + if old_instance.status != 'shipped' or not old_instance.tracking_number: + instance._was_shipped = True + + except Order.DoesNotExist: + pass + +@receiver(post_save, sender=Order) +def send_order_notifications(sender, instance, created, **kwargs): + """ + 在保存之后发送通知 + """ + if created: + return + + # 1. 处理支付成功通知 + if getattr(instance, '_was_paid', False): + try: + # 只有当订单不是活动订单时才发送普通支付成功短信 + # 活动订单会在 views.py 中单独处理(发送报名成功短信) + if not (hasattr(instance, 'activity') and instance.activity): + print(f"订单 {instance.id} 支付成功,触发短信通知流程...") + notify_admins_order_paid(instance) + notify_user_order_paid(instance) + else: + print(f"订单 {instance.id} 是活动订单,跳过普通支付短信通知(已在 views.py 处理)") + + # 清除标记防止重复发送 (虽然实例通常是新的,但保险起见) + instance._was_paid = False + except Exception as e: + print(f"发送支付成功短信失败: {str(e)}") + + # 2. 处理发货通知 + if getattr(instance, '_was_shipped', False): + try: + # 同样,活动订单不需要发送发货短信(通常活动无需发货) + if not (hasattr(instance, 'activity') and instance.activity): + print(f"订单 {instance.id} 已发货,触发短信通知流程...") + notify_user_order_shipped(instance) + else: + print(f"订单 {instance.id} 是活动订单,跳过发货短信通知") + + instance._was_shipped = False + except Exception as e: + print(f"发送发货短信失败: {str(e)}") diff --git a/backend/shop/sms_utils.py b/backend/shop/sms_utils.py new file mode 100644 index 0000000..e6cc301 --- /dev/null +++ b/backend/shop/sms_utils.py @@ -0,0 +1,144 @@ +import requests +import threading +import json +from .models import AdminPhoneNumber + +# SMS API Configuration +SMS_API_URL = "https://data.tangledup-ai.com/api/send-sms/diy" +SIGN_NAME = "叠加态科技云南" + +def send_sms(phone_number, template_code, template_params): + """ + 通用发送短信函数 (异步) + """ + def _send(): + try: + payload = { + "phone_number": phone_number, + "template_code": template_code, + "sign_name": SIGN_NAME, + "template_params": template_params + } + headers = { + "Content-Type": "application/json", + "accept": "application/json" + } + # print(f"Sending SMS to {phone_number} with params: {template_params}") + response = requests.post(SMS_API_URL, json=payload, headers=headers, timeout=15) + print(f"SMS Response for {phone_number}: {response.status_code} - {response.text}") + except Exception as e: + print(f"发送短信异常: {str(e)}") + + threading.Thread(target=_send).start() + +def notify_admins_order_paid(order): + """ + 通知管理员有新订单支付成功 + """ + # 获取激活的管理员手机号,最多3个 + admins = AdminPhoneNumber.objects.filter(is_active=True)[:3] + if not admins.exists(): + print("未配置管理员手机号,跳过管理员通知") + return + + # 构造参数 + # 模板变量: consignee, order_id, address + # order_id 格式要求: "订单编号/电话号码" + params = { + "consignee": order.customer_name or "未填写", + "order_id": f"{order.id}/{order.phone_number}", + "address": order.shipping_address or "无地址" + } + + print(f"准备发送管理员通知,共 {admins.count()} 人") + for admin in admins: + send_sms(admin.phone_number, "SMS_501735480", params) + +def notify_user_order_paid(order): + """ + 通知用户下单成功 (支付成功) + """ + if not order.phone_number: + return + + # 模板变量: user_nick, address + # 尝试获取用户昵称,如果没有则使用收货人姓名 + user_nick = order.customer_name + if order.wechat_user and order.wechat_user.nickname: + user_nick = order.wechat_user.nickname + + params = { + "user_nick": user_nick or "用户", + "address": order.shipping_address or "无地址" + } + + print(f"准备发送用户支付成功通知: {order.phone_number}") + send_sms(order.phone_number, "SMS_501850529", params) + +def notify_user_order_shipped(order): + """ + 通知用户已发货 + """ + if not order.phone_number: + return + + # 模板变量: user_nick, address, delivery_company, order_id (这里指快递单号) + user_nick = order.customer_name + if order.wechat_user and order.wechat_user.nickname: + user_nick = order.wechat_user.nickname + + params = { + "user_nick": user_nick or "用户", + "address": order.shipping_address or "无地址", + "delivery_company": order.courier_name or "快递", + "order_id": order.tracking_number or "暂无单号" + } + + print(f"准备发送用户发货通知: {order.phone_number}") + #send_sms(order.phone_number, "SMS_501650557", params) + send_sms(order.phone_number, "SMS_501665569", params) + +def notify_user_activity_signup_success(order, signup): + """ + 通知用户活动报名成功 (支付成功后) + 模板CODE: SMS_501990528 + 模板变量: user_nick, unit_name, time, address + """ + if not order.phone_number: + return + + # 1. user_nick + user_nick = order.customer_name + if order.wechat_user and order.wechat_user.nickname: + user_nick = order.wechat_user.nickname + + # 2. unit_name (Activity Title) + unit_name = f"【{signup.activity.title}】" + + # 3. time + start_time = signup.activity.start_time + # Format time as YYYY-MM-DD HH:MM + time_str = start_time.strftime("%Y-%m-%d %H:%M") if start_time else "待定" + + # 4. address + address = signup.activity.location or "线上活动" + + # 5. Handle phone number format (remove +86 or spaces if any) + phone_number = str(order.phone_number) if order.phone_number else "" + if phone_number: + phone_number = phone_number.replace("+86", "").replace(" ", "").strip() + + # Ensure phone number is valid (11 digits) + if not phone_number or len(phone_number) != 11 or not phone_number.isdigit(): + print(f"无效的手机号: {phone_number}, 跳过短信发送") + return + + params = { + "user_nick": user_nick or "用户", + "unit_name": unit_name, + "time": time_str, + "address": address + } + + print(f"准备发送活动报名成功通知: {phone_number}") + send_sms(phone_number, "SMS_501990528", params) \ No newline at end of file diff --git a/backend/shop/templates/shop/order_check.html b/backend/shop/templates/shop/order_check.html new file mode 100644 index 0000000..c243520 --- /dev/null +++ b/backend/shop/templates/shop/order_check.html @@ -0,0 +1,151 @@ + + + + + + 订单查询 - 量迹AI硬件 + + + +
+

订单状态查询

+
+ + +
+ + +
+
+ + + + \ No newline at end of file diff --git a/backend/shop/tests.py b/backend/shop/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/backend/shop/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/backend/shop/urls.py b/backend/shop/urls.py new file mode 100644 index 0000000..7b6e9e3 --- /dev/null +++ b/backend/shop/urls.py @@ -0,0 +1,31 @@ +from django.urls import path, include, re_path +from rest_framework.routers import DefaultRouter +from .views import ( + ESP32ConfigViewSet, OrderViewSet, order_check_view, + ServiceViewSet, VCCourseViewSet, ServiceOrderViewSet, + payment_finish, pay, send_sms_code, wechat_login, update_user_info, DistributorViewSet, + CourseEnrollmentViewSet, phone_login, bind_phone, WeChatUserViewSet, upload_image +) + +router = DefaultRouter() +router.register(r'configs', ESP32ConfigViewSet) +router.register(r'orders', OrderViewSet) +router.register(r'services', ServiceViewSet) +router.register(r'courses', VCCourseViewSet) +router.register(r'course-enrollments', CourseEnrollmentViewSet) +router.register(r'service-orders', ServiceOrderViewSet) +router.register(r'distributor', DistributorViewSet, basename='distributor') +router.register(r'users', WeChatUserViewSet, basename='wechatuser') + +urlpatterns = [ + re_path(r'^finish/?$', payment_finish, name='payment-finish'), + re_path(r'^pay/?$', pay, name='wechat-pay-v3'), + path('auth/send-sms/', send_sms_code, name='send-sms'), + path('wechat/login/', wechat_login, name='wechat-login'), + path('auth/phone-login/', phone_login, name='phone-login'), + path('auth/bind-phone/', bind_phone, name='bind-phone'), + path('wechat/update/', update_user_info, name='wechat-update'), + path('upload/image/', upload_image, name='upload-image'), + path('page/check-order/', order_check_view, name='check-order-page'), + path('', include(router.urls)), +] diff --git a/backend/shop/utils.py b/backend/shop/utils.py new file mode 100644 index 0000000..8f8dfc9 --- /dev/null +++ b/backend/shop/utils.py @@ -0,0 +1,88 @@ +import requests +from django.core.cache import cache +from django.core.signing import TimestampSigner, BadSignature, SignatureExpired +from .models import WeChatPayConfig, WeChatUser + +import logging + +logger = logging.getLogger(__name__) + +def get_current_wechat_user(request): + """ + 根据 Authorization 头获取当前微信用户 + 增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除), + 但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。 + """ + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return None + token = auth_header.split(' ')[1] + signer = TimestampSigner() + try: + # 签名包含 openid + openid = signer.unsign(token, max_age=86400 * 30) # 30天有效 + user = WeChatUser.objects.filter(openid=openid).first() + + if user: + return user + + # 如果没找到用户,检查是否是 Web 虚拟 OpenID + # 场景:Web 用户已被合并到小程序账号,旧 Web Token 依然有效,指向合并后的账号 + if openid.startswith('web_'): + try: + # 格式: web_13800138000 + parts = openid.split('_', 1) + if len(parts) == 2: + phone = parts[1] + # 尝试通过手机号查找(查找合并后的主账号) + user = WeChatUser.objects.filter(phone_number=phone).first() + if user: + return user + except Exception: + pass + + return None + except (BadSignature, SignatureExpired): + return None + +def get_access_token(config=None, force_refresh=False): + """ + 获取微信接口调用凭证 (client_credential) + """ + # 尝试从缓存获取 + cache_key = 'wechat_access_token' + if config: + cache_key = f'wechat_access_token_{config.app_id}' + + if not force_refresh: + token = cache.get(cache_key) + if token: + return token + + if not config: + # 优先查找指定 AppID + config = WeChatPayConfig.objects.filter(app_id='wxdf2ca73e6c0929f0').first() + if not config: + config = WeChatPayConfig.objects.filter(is_active=True).first() + + if not config or not config.app_id or not config.app_secret: + logger.error("No active WeChatPayConfig found or missing app_id/app_secret") + return None + + url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={config.app_id}&secret={config.app_secret}" + try: + response = requests.get(url, timeout=10) + data = response.json() + + if 'access_token' in data: + token = data['access_token'] + expires_in = data.get('expires_in', 7200) + # 缓存 Token,留出 200 秒缓冲时间 + cache.set(cache_key, token, expires_in - 200) + return token + else: + logger.error(f"获取 AccessToken 失败: {data}") + except Exception as e: + logger.error(f"获取 AccessToken 异常: {str(e)}", exc_info=True) + + return None diff --git a/backend/shop/views.py b/backend/shop/views.py new file mode 100644 index 0000000..ac6f1e3 --- /dev/null +++ b/backend/shop/views.py @@ -0,0 +1,1693 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action, api_view, parser_classes +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.response import Response +from django.core.files.storage import default_storage +from django.core.files.base import ContentFile +from django.shortcuts import render +from django.views.decorators.csrf import csrf_exempt +from django.http import HttpResponse +from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample +from .models import ESP32Config, Order, WeChatPayConfig, Service, VCCourse, ServiceOrder, Salesperson, CommissionLog, WeChatUser, Distributor, Withdrawal, CourseEnrollment +from .serializers import ESP32ConfigSerializer, OrderSerializer, ServiceSerializer, VCCourseSerializer, ServiceOrderSerializer, WeChatUserSerializer, DistributorSerializer, WithdrawalSerializer, CommissionLogSerializer, CourseEnrollmentSerializer +from .utils import get_access_token, get_current_wechat_user +from .services import handle_post_payment +from django.db import transaction, models +from django.core.signing import TimestampSigner, BadSignature, SignatureExpired +from django.contrib.auth.models import User +from wechatpayv3 import WeChatPay, WeChatPayType +from wechatpayv3.core import Core +import xml.etree.ElementTree as ET +import uuid +import time +import hashlib +import json +import os +import base64 +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from django.conf import settings +import requests +import random +import threading +import logging +import string +from django.core.cache import cache + +logger = logging.getLogger(__name__) +from time import sleep + +# 猴子补丁:绕过微信支付响应签名验证 +# 原因是:在开发环境或证书未能正确下载时,SDK 会因为无法验证微信返回的签名而抛出异常。 +# 但实际上请求已经成功发送到了微信,且微信已经返回了支付链接。 +original_request = Core.request +def patched_request(self, *args, **kwargs): + # 强制设置 skip_verify 为 True,同时保留其他所有参数的默认值 + kwargs['skip_verify'] = True + return original_request(self, *args, **kwargs) +Core.request = patched_request + +def get_wechat_pay_client(pay_type=WeChatPayType.NATIVE, appid=None, config=None): + """ + 获取微信支付 V3 客户端实例的辅助函数 + """ + print(f"正在获取微信支付配置...") + + wechat_config = config + if not wechat_config: + wechat_config = WeChatPayConfig.objects.filter(is_active=True).first() + + if not wechat_config: + print("错误: 数据库中没有激活的 WeChatPayConfig") + return None, "支付配置未找到" + + print(f"找到配置: ID={wechat_config.id}, MCH_ID={wechat_config.mch_id}") + + # 1. 严格清理所有配置项的空格和换行符 + mch_id = str(wechat_config.mch_id).strip() + + # 如果传入了 appid,优先使用传入的 + if not appid: + appid = str(wechat_config.app_id).strip() + else: + appid = str(appid).strip() + + apiv3_key = str(wechat_config.apiv3_key).strip() + serial_no = str(wechat_config.mch_cert_serial_no).strip() + notify_url = str(wechat_config.notify_url).strip() + + # 查找私钥文件 + private_key = None + possible_key_paths = [ + os.path.join(settings.BASE_DIR, 'certs', 'apiclient_key.pem'), + os.path.join(settings.BASE_DIR, 'static', 'cert', 'apiclient_key.pem'), + os.path.join(settings.BASE_DIR, 'staticfiles', 'cert', 'apiclient_key.pem'), + os.path.join(settings.BASE_DIR, 'backend', 'certs', 'apiclient_key.pem'), + ] + + for key_path in possible_key_paths: + if os.path.exists(key_path): + try: + with open(key_path, 'r', encoding='utf-8') as f: + private_key = f.read() + break + except Exception as e: + print(f"尝试读取私钥文件 {key_path} 失败: {str(e)}") + + if not private_key: + private_key = wechat_config.mch_private_key + + if private_key: + # 统一处理私钥格式 + private_key = private_key.strip() + + # 移除可能存在的首尾空白字符 + if 'BEGIN PRIVATE KEY' in private_key: + # 如果已经包含 PEM 头,尝试清理并重新格式化 + lines = private_key.split('\n') + clean_lines = [line.strip() for line in lines if line.strip()] + private_key = '\n'.join(clean_lines) + else: + # 如果没有头尾,说明是纯 base64 内容,尝试添加 + private_key = f"-----BEGIN PRIVATE KEY-----\n{private_key}\n-----END PRIVATE KEY-----" + + if not private_key: + return None, "缺少商户私钥 (未找到文件且数据库配置为空)" + + # 确保回调地址以斜杠结尾 + if not notify_url.endswith('/'): + notify_url += '/' + + cert_dir = os.path.join(settings.BASE_DIR, 'certs') + if not os.path.exists(cert_dir): + os.makedirs(cert_dir) + + try: + wxpay = WeChatPay( + wechatpay_type=pay_type, + mchid=mch_id, + private_key=private_key, + cert_serial_no=serial_no, + apiv3_key=apiv3_key, + appid=appid, + notify_url=notify_url, + cert_dir=cert_dir + ) + # 保存私钥内容以便后续手动签名使用 + wxpay._private_key_content = private_key + return wxpay, None + except Exception as e: + return None, str(e) + +@extend_schema( + summary="发送短信验证码", + description="发送6位数字验证码到指定手机号", + request={ + 'application/json': { + 'type': 'object', + 'properties': { + 'phone_number': {'type': 'string', 'description': '手机号码'}, + }, + 'required': ['phone_number'] + } + }, + responses={ + 200: OpenApiExample('成功', value={'message': '验证码已发送'}), + 400: OpenApiExample('失败', value={'error': '手机号不能为空'}) + } +) +@api_view(['POST']) +def send_sms_code(request): + phone = request.data.get('phone_number') + if not phone: + return Response({'error': '手机号不能为空'}, status=status.HTTP_400_BAD_REQUEST) + + # 生成6位验证码 + code = ''.join([str(random.randint(0, 9)) for _ in range(6)]) + + # 缓存验证码 (5分钟有效) + cache_key = f"sms_code_{phone}" + cache.set(cache_key, code, timeout=300) + + # 异步发送短信 + def _send_async(): + try: + api_url = "https://data.tangledup-ai.com/api/send-sms" + payload = { + "phone_number": phone, + "code": code, + "template_code": "SMS_493295002", + "sign_name": "叠加态科技云南", + "additionalProp1": {} + } + headers = { + "Content-Type": "application/json", + "accept": "application/json" + } + response = requests.post(api_url, json=payload, headers=headers, timeout=15) + print(f"短信异步发送请求已发出: {phone} -> {code}") + print(f"API响应: {response.status_code} - {response.text}") + except Exception as e: + print(f"异步发送短信异常: {str(e)}") + + + threading.Thread(target=_send_async).start() + sleep(2) + # 立即返回成功,无需等待外部API响应 + return Response({'message': '验证码已发送'}) + +@extend_schema( + summary="微信支付 V3 Native 下单", + description="创建订单并获取微信支付二维码链接(code_url)。参数包括商品ID、数量、客户信息等。", + request={ + 'application/json': { + 'type': 'object', + 'properties': { + 'goodid': {'type': 'integer', 'description': '商品ID (ESP32Config ID)'}, + 'quantity': {'type': 'integer', 'description': '购买数量', 'default': 1}, + 'customer_name': {'type': 'string', 'description': '收货人姓名'}, + 'phone_number': {'type': 'string', 'description': '联系电话'}, + 'shipping_address': {'type': 'string', 'description': '详细收货地址'}, + 'ref_code': {'type': 'string', 'description': '推荐码 (销售员代码)', 'nullable': True}, + }, + 'required': ['goodid', 'customer_name', 'phone_number', 'shipping_address'] + } + }, + responses={ + 200: OpenApiExample( + '成功响应', + value={ + 'code_url': 'weixin://wxpay/bizpayurl?pr=XXXXX', + 'out_trade_no': 'PAY123T1738800000', + 'order_id': 123, + 'message': '下单成功' + } + ), + 400: OpenApiExample( + '参数错误/配置不全', + value={'error': '缺少必要参数: ...'} + ) + } +) +@api_view(['POST']) +def pay(request): + """ + 微信支付 V3 Native 下单接口 + 参数: goodid, quantity, customer_name, phone_number, shipping_address, ref_code + """ + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 进入 pay 接口") + print(f"Request Headers: {request.headers}") + print(f"Request Data: {request.data}") + + # 1. 获取并验证请求参数 + good_id = request.data.get('goodid') + order_type = request.data.get('type', 'config') # 默认为 config + quantity = int(request.data.get('quantity', 1)) + customer_name = request.data.get('customer_name') + phone_number = request.data.get('phone_number') + shipping_address = request.data.get('shipping_address') + ref_code = request.data.get('ref_code') + + if not all([good_id, customer_name, phone_number, shipping_address]): + missing_params = [] + if not good_id: missing_params.append('goodid') + if not customer_name: missing_params.append('customer_name') + if not phone_number: missing_params.append('phone_number') + if not shipping_address: missing_params.append('shipping_address') + print(f"支付接口缺少参数: {missing_params}, 接收到的数据: {request.data}") + return Response({'error': f'缺少必要参数: {", ".join(missing_params)}'}, status=status.HTTP_400_BAD_REQUEST) + + # 2. 获取支付配置并初始化客户端 + wxpay, error_msg = get_wechat_pay_client() + if not wxpay: + print(f"支付配置错误: {error_msg}") + return Response({'error': f'支付配置错误: {error_msg}'}, status=status.HTTP_400_BAD_REQUEST) + + # 3. 查找商品和销售员,创建订单 + product = None + if order_type == 'course': + try: + product = VCCourse.objects.get(id=good_id) + except VCCourse.DoesNotExist: + print(f"课程不存在: {good_id}") + return Response({'error': f'找不到 ID 为 {good_id} 的课程'}, status=status.HTTP_404_NOT_FOUND) + else: + try: + product = ESP32Config.objects.get(id=good_id) + except ESP32Config.DoesNotExist: + print(f"商品不存在: {good_id}") + return Response({'error': f'找不到 ID 为 {good_id} 的商品'}, status=status.HTTP_404_NOT_FOUND) + + # 检查库存 (仅针对硬件) + if product.stock < quantity: + return Response({'error': f'库存不足,仅剩 {product.stock} 件'}, status=status.HTTP_400_BAD_REQUEST) + + salesperson = None + if ref_code: + from .models import Salesperson + salesperson = Salesperson.objects.filter(code=ref_code).first() + + # 尝试获取当前登录用户 (如果请求头带有 Authorization) + wechat_user = get_current_wechat_user(request) + + total_price = product.price * quantity + amount_in_cents = int(total_price * 100) + + order_kwargs = { + 'quantity': quantity, + 'total_price': total_price, + 'customer_name': customer_name, + 'phone_number': phone_number, + 'shipping_address': shipping_address, + 'salesperson': salesperson, + 'wechat_user': wechat_user, + 'status': 'pending' + } + + if order_type == 'course': + order_kwargs['course'] = product + else: + order_kwargs['config'] = product + + order = Order.objects.create(**order_kwargs) + + # 扣减库存 (仅针对硬件) + if order_type != 'course': + product.stock -= quantity + product.save() + + # 4. 调用微信支付接口 + out_trade_no = f"PAY{order.id}T{int(time.time())}" + if order_type == 'course': + description = f"报名 {product.title}" + else: + description = f"购买 {product.name} x {quantity}" + + # 保存商户订单号到数据库,方便后续查询 + order.out_trade_no = out_trade_no + order.save() + + try: + # 显式获取并打印 notify_url,确保它与你配置的一致 + notify_url = wxpay._notify_url + print(f"========================================") + print(f"发起微信支付 Native 下单") + print(f"商户订单号: {out_trade_no}") + print(f"回调地址 (notify_url): {notify_url}") + print(f"========================================") + + code, message = wxpay.pay( + description=description, + out_trade_no=out_trade_no, + amount={ + 'total': amount_in_cents, + 'currency': 'CNY' + }, + notify_url=notify_url # 显式传入,确保库使用该地址 + ) + + result = json.loads(message) + if code in range(200, 300): + code_url = result.get('code_url') + # 打印到控制台 + print(f"========================================") + print(f"微信支付 V3 Native 下单成功!") + print(f"订单 ID: {order.id}") + print(f"商户订单号: {out_trade_no}") + product_name = getattr(product, 'name', getattr(product, 'title', 'Unknown Product')) + print(f"商品: {product_name} x {quantity}") + print(f"总额: {total_price} 元") + print(f"code_url: {code_url}") + print(f"========================================") + + return Response({ + 'code_url': code_url, + 'out_trade_no': out_trade_no, + 'order_id': order.id, + 'message': '下单成功' + }) + else: + print(f"微信支付 V3 下单失败: {message}") + order.delete() # 下单失败则删除刚刚创建的订单 + return Response({ + 'error': '微信支付官方接口返回错误', + 'detail': result + }, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + import traceback + print(f"调用微信支付接口发生异常: {str(e)}") + traceback.print_exc() + if 'order' in locals() and order.id: order.delete() + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + +@csrf_exempt +def payment_finish(request): + """ + 微信支付 V3 回调接口 + 参考文档: https://pay.weixin.qq.com/doc/v3/merchant/4012071382 + """ + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] 收到回调请求: {request.method} {request.path}") + + if request.method != 'POST': + return HttpResponse("Method not allowed", status=405) + + # 1. 获取回调头信息 + headers = { + 'Wechatpay-Timestamp': request.headers.get('Wechatpay-Timestamp'), + 'Wechatpay-Nonce': request.headers.get('Wechatpay-Nonce'), + 'Wechatpay-Signature': request.headers.get('Wechatpay-Signature'), + 'Wechatpay-Serial': request.headers.get('Wechatpay-Serial'), + 'Wechatpay-Signature-Type': request.headers.get('Wechatpay-Signature-Type', 'WECHATPAY2-SHA256-RSA2048'), + } + + body = request.body.decode('utf-8') + print(f"收到回调 Body (长度: {len(body)}):") + print(f"--- BODY START ---") + print(body) + print(f"--- BODY END ---") + + # 打印所有微信支付相关的头信息 + print("收到回调 Headers:") + for key, value in request.headers.items(): + if key.lower().startswith('wechatpay'): + print(f" {key}: {value}") + + try: + # 2. 获取支付配置并初始化 + wechat_config = WeChatPayConfig.objects.filter(is_active=True).first() + if not wechat_config: + print("错误: 数据库中没有启用的微信支付配置") + return HttpResponse("Config not found", status=500) + + print(f"当前使用的配置 ID: {wechat_config.id}, 商户号: {wechat_config.mch_id}") + + wxpay, error_msg = get_wechat_pay_client() + if not wxpay: + return HttpResponse(error_msg, status=500) + + # 3. 解析并校验基础信息 + try: + data = json.loads(body) + print(f"解析后的回调数据概览: id={data.get('id')}, event_type={data.get('event_type')}, resource_type={data.get('resource_type')}") + except Exception as json_e: + print(f"JSON 解析失败: {str(json_e)}") + return HttpResponse("Invalid JSON", status=400) + + # 4. 尝试解密 + apiv3_key = str(wechat_config.apiv3_key).strip() + print(f"正在使用 Key[{apiv3_key[:3]}...{apiv3_key[-3:]}] (长度: {len(apiv3_key)}) 尝试解密...") + + # 优先使用 SDK 的 callback 方法 + try: + print("尝试使用 SDK callback 方法解密并验证签名...") + # 调试:打印 headers 关键信息 + print(f"Headers: Timestamp={headers.get('Wechatpay-Timestamp')}, Serial={headers.get('Wechatpay-Serial')}") + + result_str = wxpay.callback(headers, body) + if result_str: + result = json.loads(result_str) + print(f"SDK 解密成功: {result.get('out_trade_no')}") + else: + print("SDK callback 返回空,可能是签名验证失败。") + raise Exception("SDK callback returned None") + except Exception as sdk_e: + print(f"SDK callback 失败: {str(sdk_e)},尝试手动解密...") + + resource = data.get('resource', {}) + ciphertext = resource.get('ciphertext') + nonce = resource.get('nonce') + associated_data = resource.get('associated_data') + + print(f"提取的解密参数: nonce={nonce}, associated_data={associated_data}, ciphertext_len={len(ciphertext) if ciphertext else 0}") + + try: + if not all([ciphertext, nonce, apiv3_key]): + raise ValueError(f"缺少解密必要参数: ciphertext={bool(ciphertext)}, nonce={bool(nonce)}, key={bool(apiv3_key)}") + + if len(apiv3_key) != 32: + raise ValueError(f"APIV3 Key 长度错误: 预期 32 字节,实际 {len(apiv3_key)} 字节") + + aesgcm = AESGCM(apiv3_key.encode('utf-8')) + decrypted_data = aesgcm.decrypt( + nonce.encode('utf-8'), + base64.b64decode(ciphertext), + associated_data.encode('utf-8') if associated_data else b"" + ) + result = json.loads(decrypted_data.decode('utf-8')) + print(f"手动解密成功: {result.get('out_trade_no')}") + except Exception as e: + import traceback + error_type = type(e).__name__ + error_msg = str(e) + print(f"手动解密依然失败: {error_type}: {error_msg}") + if "InvalidTag" in error_msg or error_type == "InvalidTag": + print(f"提示: InvalidTag 通常意味着 Key 正确但与数据不匹配。") + print(f"当前使用的 Key: {apiv3_key}") + print(f"请确认该 Key 是否确实是商户号 {wechat_config.mch_id} 的 APIV3 密钥。") + traceback.print_exc() + return HttpResponse("Decryption failed", status=400) + + # 5. 订单处理 (保持原有逻辑) + out_trade_no = result.get('out_trade_no') + transaction_id = result.get('transaction_id') + trade_state = result.get('trade_state') + + if trade_state == 'SUCCESS': + try: + order = None + if out_trade_no.startswith('PAY'): + t_index = out_trade_no.find('T') + order_id = int(out_trade_no[3:t_index]) + order = Order.objects.get(id=order_id) + else: + order = Order.objects.get(out_trade_no=out_trade_no) + + if order and order.status != 'paid': + order.status = 'paid' + order.wechat_trade_no = transaction_id + order.save() + print(f"订单 {order.id} 状态已更新") + + # 6. 处理支付后业务逻辑 (活动报名、佣金、短信通知) + handle_post_payment(order) + + except Exception as e: + print(f"订单更新失败: {str(e)}") + + return HttpResponse(status=200) + + except Exception as e: + import traceback + print(f"回调处理发生异常: {str(e)}") + traceback.print_exc() + return HttpResponse(str(e), status=500) + +@extend_schema_view( + list=extend_schema(summary="获取VC课程列表", description="获取所有可用的VC课程"), + retrieve=extend_schema(summary="获取VC课程详情", description="获取指定VC课程的详细信息") +) +class VCCourseViewSet(viewsets.ReadOnlyModelViewSet): + """ + VC课程列表和详情 + """ + queryset = VCCourse.objects.all() + serializer_class = VCCourseSerializer + +class CourseEnrollmentViewSet(viewsets.ModelViewSet): + """ + 课程报名管理 + """ + queryset = CourseEnrollment.objects.all().order_by('-created_at') + serializer_class = CourseEnrollmentSerializer + +def order_check_view(request): + """ + 订单查询页面视图 + """ + return render(request, 'shop/order_check.html') + +@extend_schema_view( + list=extend_schema(summary="获取AI服务列表", description="获取所有可用的AI服务"), + retrieve=extend_schema(summary="获取AI服务详情", description="获取指定AI服务的详细信息") +) +class ServiceViewSet(viewsets.ReadOnlyModelViewSet): + """ + AI服务列表和详情 + """ + queryset = Service.objects.all() + serializer_class = ServiceSerializer + +class ServiceOrderViewSet(viewsets.ModelViewSet): + """ + AI服务订单管理 + """ + queryset = ServiceOrder.objects.all() + serializer_class = ServiceOrderSerializer + +@extend_schema_view( + list=extend_schema(summary="获取ESP32配置列表", description="获取所有可用的ESP32硬件配置选项"), + retrieve=extend_schema(summary="获取ESP32配置详情", description="获取指定ESP32配置的详细信息") +) +class ESP32ConfigViewSet(viewsets.ReadOnlyModelViewSet): + """ + 提供ESP32配置选项的列表和详情 + """ + queryset = ESP32Config.objects.all() + serializer_class = ESP32ConfigSerializer + + +class OrderViewSet(viewsets.ModelViewSet): + """ + 订单管理视图集 + 支持创建订单和查询订单状态 + """ + queryset = Order.objects.all() + serializer_class = OrderSerializer + + def get_queryset(self): + """ + 如果用户已通过微信登录,只返回自己的订单 + 否则(如管理员)返回所有订单 + """ + queryset = super().get_queryset() + user = get_current_wechat_user(self.request) + if user: + return queryset.filter(wechat_user=user).order_by('-created_at') + return queryset.order_by('-created_at') + + def create(self, request, *args, **kwargs): + print(f"Creating order with data: {request.data}") + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + print(f"Order validation failed: {serializer.errors}") + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + self.perform_create(serializer) + headers = self.get_success_headers(serializer.data) + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) + + def perform_create(self, serializer): + """ + 创建订单时自动关联当前微信用户 + """ + user = get_current_wechat_user(self.request) + instance = serializer.save(wechat_user=user) + + # Check if free course and set to paid + if instance.course and instance.course.price == 0 and instance.status == 'pending': + instance.status = 'paid' + instance.save() + # Trigger post payment logic + from .services import handle_post_payment + handle_post_payment(instance) + + @action(detail=True, methods=['post']) + def prepay_miniprogram(self, request, pk=None): + """ + 小程序支付下单 (返回 wx.requestPayment 所需参数) + """ + order = self.get_object() + if order.status == 'paid': + return Response({'error': '订单已支付'}, status=status.HTTP_400_BAD_REQUEST) + + user = get_current_wechat_user(request) + if not user: + return Response({'error': 'Unauthorized'}, status=401) + + # 绑定用户 + if not order.wechat_user: + order.wechat_user = user + order.save() + + # 小程序 AppID + miniprogram_appid = 'wxdf2ca73e6c0929f0' + + # 尝试查找特定配置 + wechat_config = WeChatPayConfig.objects.filter(app_id=miniprogram_appid).first() + if not wechat_config: + wechat_config = WeChatPayConfig.objects.filter(is_active=True).first() + + if not wechat_config: + return Response({'error': '支付系统维护中'}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + + # 初始化支付客户端,强制使用小程序 AppID + wxpay, error_msg = get_wechat_pay_client( + pay_type=WeChatPayType.JSAPI, + appid=miniprogram_appid, + config=wechat_config + ) + if not wxpay: + return Response({'error': error_msg}, status=500) + + amount_in_cents = int(order.total_price * 100) + out_trade_no = f"PAY{order.id}T{int(time.time())}" + order.out_trade_no = out_trade_no + order.save() + + try: + # 动态生成描述 + if order.config: + description = f"购买 {order.config.name} x {order.quantity}" + elif order.course: + description = f"报名 {order.course.title}" + else: + description = f"支付订单 {order.id}" + + # 强制修正回调地址为正确的后端接口地址 + # 用户配置可能是 /pay (前端页面),我们需要的是 /api/finish/ (后端回调接口) + current_notify = wechat_config.notify_url + if 'quant-speed.com' in current_notify: + notify_url = "https://market.quant-speed.com/api/finish/" + else: + notify_url = current_notify # Fallback + + print(f"准备发起微信支付(小程序):") + print(f" OutTradeNo: {out_trade_no}") + print(f" Amount: {amount_in_cents} 分") + print(f" OpenID: {user.openid}") + print(f" NotifyURL: {notify_url}") + + # 统一下单 (JSAPI) + code, message = wxpay.pay( + description=description, + out_trade_no=out_trade_no, + amount={'total': amount_in_cents, 'currency': 'CNY'}, + payer={'openid': user.openid}, # 小程序支付必须传 openid + notify_url=notify_url + ) + + print(f"微信支付响应: Code={code}, Message={message}") + + result = json.loads(message) + if code in range(200, 300): + prepay_id = result.get('prepay_id') + + # 生成小程序调起支付所需的参数 + timestamp = str(int(time.time())) + nonce_str = str(uuid.uuid4()).replace('-', '') + package = f"prepay_id={prepay_id}" + + # 再次签名 (小程序端需要的签名) + # 注意:WeChatPayV3 SDK 可能没有直接提供生成小程序签名的 helper,需手动计算 + # 签名串格式:appId\ntimeStamp\nnonceStr\npackage\n + message_build = f"{miniprogram_appid}\n{timestamp}\n{nonce_str}\n{package}\n" + + print(f"待签名字符串:\n{repr(message_build)}") + + # 手动签名 + from cryptography.hazmat.backends import default_backend + + private_key_obj = serialization.load_pem_private_key( + wxpay._private_key_content.encode('utf-8'), + password=None, + backend=default_backend() + ) + + signature = base64.b64encode( + private_key_obj.sign( + message_build.encode('utf-8'), + padding.PKCS1v15(), + hashes.SHA256() + ) + ).decode('utf-8') + + print(f"生成的签名: {signature}") + + return Response({ + 'timeStamp': timestamp, + 'nonceStr': nonce_str, + 'package': package, + 'signType': 'RSA', + 'paySign': signature, + 'out_trade_no': out_trade_no + }) + else: + return Response({'error': '微信下单失败', 'detail': result}, status=400) + + except Exception as e: + import traceback + traceback.print_exc() + print(f"Prepay failed with error: {str(e)}") + return Response({'error': str(e)}, status=500) + + @action(detail=False, methods=['get']) + def lookup(self, request): + """ + 根据电话号码查询订单状态 + URL: /api/orders/lookup/?phone=13800138000 + """ + phone = request.query_params.get('phone') + if not phone: + return Response({'error': '请提供电话号码'}, status=status.HTTP_400_BAD_REQUEST) + + # 简单校验 + orders = Order.objects.filter(phone_number=phone).order_by('-created_at') + serializer = self.get_serializer(orders, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['post'], authentication_classes=[], permission_classes=[]) + def my_orders(self, request): + """ + 查询我的订单 + 需要提供手机号和验证码 + """ + phone = request.data.get('phone_number') + code = request.data.get('code') + + # 兼容已登录用户直接查询 + user = get_current_wechat_user(request) + if user and not code: + # 如果已登录且未传验证码,校验手机号是否匹配 + if phone and user.phone_number != phone: + return Response({'error': '无权查询该手机号的订单'}, status=status.HTTP_403_FORBIDDEN) + # 返回当前用户的订单 + orders = Order.objects.filter(wechat_user=user).order_by('-created_at') + serializer = self.get_serializer(orders, many=True) + return Response(serializer.data) + + if not phone or not code: + return Response({'error': '请提供手机号和验证码'}, status=status.HTTP_400_BAD_REQUEST) + + # 验证验证码 + cache_key = f"sms_code_{phone}" + cached_code = cache.get(cache_key) + + # 开发/测试方便,如果验证码是 888888 且没有缓存,允许通过(可选,但为了演示方便) + # if code == '888888': pass + + if not cached_code or cached_code != code: + return Response({'error': '验证码错误或已过期'}, status=status.HTTP_400_BAD_REQUEST) + + # 查询订单 + orders = Order.objects.filter(phone_number=phone).order_by('-created_at') + serializer = self.get_serializer(orders, many=True) + + # 验证通过后清除验证码 (防止重放) + cache.delete(cache_key) + + return Response(serializer.data) + + @action(detail=True, methods=['post']) + def initiate_payment(self, request, pk=None): + """ + 发起支付请求 + 获取微信支付配置并生成签名 + """ + order = self.get_object() + + if order.status == 'paid': + return Response({'error': '订单已支付'}, status=status.HTTP_400_BAD_REQUEST) + + # 获取微信支付配置 + wechat_config = WeChatPayConfig.objects.filter(is_active=True).first() + if not wechat_config: + # 如果没有配置,为了演示方便,回退到模拟数据,或者报错 + # 这里我们报错提示需要在后台配置 + return Response({'error': '支付系统维护中 (未配置支付参数)'}, status=status.HTTP_503_SERVICE_UNAVAILABLE) + + # 构造支付参数 + # 注意:实际生产环境必须在此处调用微信【统一下单】接口获取真实的 prepay_id + # 这里为了演示完整流程,我们使用配置中的参数生成合法的签名结构,但 prepay_id 是模拟的 + + app_id = wechat_config.app_id + timestamp = str(int(time.time())) + nonce_str = str(uuid.uuid4()).replace('-', '') + + # 模拟的 prepay_id + prepay_id = f"wx{str(uuid.uuid4()).replace('-', '')}" + package = f"prepay_id={prepay_id}" + sign_type = 'MD5' + + # 生成签名 (WeChat Pay V2 MD5 Signature) + # 签名步骤: + # 1. 设所有发送或者接收到的数据为集合M,将集合M内非空参数值的参数按照参数名ASCII码从小到大排序(字典序) + # 2. 使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串stringA + # 3. 在stringA最后拼接上key得到stringSignTemp字符串,并对stringSignTemp进行MD5运算,再将得到的字符串所有字符转换为大写 + + stringA = f"appId={app_id}&nonceStr={nonce_str}&package={package}&signType={sign_type}&timeStamp={timestamp}" + string_sign_temp = f"{stringA}&key={wechat_config.api_key}" + pay_sign = hashlib.md5(string_sign_temp.encode('utf-8')).hexdigest().upper() + + payment_params = { + 'appId': app_id, + 'timeStamp': timestamp, + 'nonceStr': nonce_str, + 'package': package, + 'signType': sign_type, + 'paySign': pay_sign, + 'orderId': order.id, + 'amount': str(order.total_price) + } + + return Response(payment_params) + + @action(detail=True, methods=['get']) + def query_status(self, request, pk=None): + """ + 主动向微信查询订单支付状态 + URL: /api/orders/{id}/query_status/ + 注意:绕过 get_queryset 的过滤,以便未登录或未绑定用户的订单也能查询 + """ + try: + order = Order.objects.get(pk=pk) + except Order.DoesNotExist: + return Response({'error': '订单不存在'}, status=status.HTTP_404_NOT_FOUND) + + # 如果已经支付了,直接返回 + if order.status == 'paid': + return Response({'status': 'paid', 'message': '订单已支付'}) + + # 初始化微信支付客户端 + wxpay, error_msg = get_wechat_pay_client() + if not wxpay: + return Response({'error': error_msg}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + # 构造商户订单号 (需与下单时一致) + # 注意:由于下单时带了时间戳,我们需要从已有的记录中查找,或者重新构造 + # 这里的逻辑是:尝试根据 order.id 查找可能的 out_trade_no + # 在实际生产中,建议在 Order 模型中增加一个 out_trade_no 字段记录下单时的单号 + + # 优先使用数据库记录的 out_trade_no,如果没有,再尝试从参数获取 + out_trade_no = order.out_trade_no or request.query_params.get('out_trade_no') + + if not out_trade_no: + return Response({'error': '订单记录中缺少商户订单号,且未提供 out_trade_no 参数'}, status=status.HTTP_400_BAD_REQUEST) + + try: + print(f"主动查询微信订单状态: out_trade_no={out_trade_no}") + code, message = wxpay.query(out_trade_no=out_trade_no) + result = json.loads(message) + + if code in range(200, 300): + trade_state = result.get('trade_state') + print(f"查询结果: {trade_state}") + + if trade_state == 'SUCCESS': + order.status = 'paid' + order.wechat_trade_no = result.get('transaction_id') + order.save() + + # 处理支付后逻辑 + handle_post_payment(order) + + return Response({'status': 'paid', 'message': '支付成功', 'detail': result}) + + return Response({'status': 'pending', 'trade_state': trade_state, 'message': result.get('trade_state_desc')}) + else: + return Response({'error': '查询失败', 'detail': result}, status=status.HTTP_400_BAD_REQUEST) + + except Exception as e: + return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=True, methods=['post']) + def confirm_payment(self, request, pk=None): + """ + 模拟支付成功回调/确认 + """ + order = self.get_object() + order.status = 'paid' + order.wechat_trade_no = f"WX_{str(uuid.uuid4())[:18]}" + order.save() + + handle_post_payment(order) + + return Response({'status': 'success', 'message': '支付成功'}) + + + +@extend_schema( + summary="微信小程序登录", + description="支持通过 code 登录,以及可选的 phone_code 用于直接获取手机号并合并 Web 用户账号。同时支持传入用户基本信息(nickname, avatar_url, gender, country, province, city)。", + request={ + 'application/json': { + 'properties': { + 'code': {'type': 'string', 'description': 'wx.login获取的code'}, + 'phone_code': {'type': 'string', 'description': 'getPhoneNumber获取的code (可选)'}, + 'nickname': {'type': 'string', 'description': '昵称 (可选)'}, + 'avatar_url': {'type': 'string', 'description': '头像URL (可选)'}, + 'gender': {'type': 'integer', 'description': '性别 0未知 1男 2女 (可选)'}, + 'country': {'type': 'string', 'description': '国家 (可选)'}, + 'province': {'type': 'string', 'description': '省份 (可选)'}, + 'city': {'type': 'string', 'description': '城市 (可选)'} + }, + 'required': ['code'] + } + }, + responses={200: {'properties': {'token': {'type': 'string'}, 'openid': {'type': 'string'}}}} +) +@api_view(['POST']) +def wechat_login(request): + code = request.data.get('code') + phone_code = request.data.get('phone_code') + + # 获取可选的用户信息 + nickname = request.data.get('nickname') + avatar_url = request.data.get('avatar_url') + gender = request.data.get('gender') + country = request.data.get('country') + province = request.data.get('province') + city = request.data.get('city') + + print("="*20 + " 小程序登录调试 " + "="*20) + print(f"收到登录请求: code={code}") + print(f"用户信息: nickname={nickname}, gender={gender}") + print(f"头像URL: {avatar_url}") + print(f"位置信息: country={country}, province={province}, city={city}") + print(f"完整数据: {request.data}") + print("="*50) + + if not code: + return Response({'error': 'Code is required'}, status=400) + + # 1. 获取配置 (优先使用指定 AppID) + target_app_id = 'wxdf2ca73e6c0929f0' + config = WeChatPayConfig.objects.filter(app_id=target_app_id).first() + if not config: + config = WeChatPayConfig.objects.filter(is_active=True).first() + + if not config or not config.app_id or not config.app_secret: + return Response({'error': 'WeChat config missing'}, status=500) + + # 2. 换取 OpenID + url = f"https://api.weixin.qq.com/sns/jscode2session?appid={config.app_id}&secret={config.app_secret}&js_code={code}&grant_type=authorization_code" + try: + res = requests.get(url, timeout=10) + data = res.json() + except Exception as e: + return Response({'error': str(e)}, status=500) + + if 'errcode' in data and data['errcode'] != 0: + return Response({'error': data.get('errmsg')}, status=400) + + openid = data.get('openid') + session_key = data.get('session_key') + unionid = data.get('unionid') + + # 3. 处理手机号与用户合并逻辑 + user = None + phone_number = None + + if phone_code: + try: + access_token = get_access_token(config) + if access_token: + phone_url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={access_token}" + phone_res = requests.post(phone_url, json={'code': phone_code}, timeout=5) + phone_data = phone_res.json() + + # Retry if access token is invalid or expired + if phone_data.get('errcode') in [40001, 40014, 42001]: + print(f"Access token invalid/expired ({phone_data.get('errcode')}), refreshing...") + access_token = get_access_token(config, force_refresh=True) + if access_token: + phone_url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={access_token}" + phone_res = requests.post(phone_url, json={'code': phone_code}, timeout=5) + phone_data = phone_res.json() + + if phone_data.get('errcode') == 0: + phone_info = phone_data.get('phone_info') + phone_number = phone_info.get('purePhoneNumber') + else: + print(f"获取手机号API返回错误: {phone_data}") + else: + print("获取 AccessToken 失败,无法解密手机号") + except Exception as e: + print(f"获取手机号异常: {str(e)}") + + + try: + with transaction.atomic(): + # 查找已存在的 OpenID 用户 (小程序用户) + mp_user = WeChatUser.objects.select_for_update().filter(openid=openid).first() + + # 查找已存在的手机号用户 (可能是 Web 用户或已绑定的 MP 用户) + phone_user = None + if phone_number: + phone_user = WeChatUser.objects.select_for_update().filter(phone_number=phone_number).first() + + if mp_user and phone_user: + if mp_user != phone_user: + # 【合并场景】: 小程序用户 和 手机号用户 都存在且不同 + # 规则: 只要手机号一致,强制合并。以当前 OpenID (mp_user) 为准,吸纳旧用户 (phone_user) 的数据。 + + # 1. 迁移订单 + Order.objects.filter(wechat_user=phone_user).update(wechat_user=mp_user) + + # 2. 迁移社区数据 (延迟导入避免循环引用) + from community.models import ActivitySignup, Topic, Reply + ActivitySignup.objects.filter(user=phone_user).update(user=mp_user) + Topic.objects.filter(author=phone_user).update(author=mp_user) + Reply.objects.filter(author=phone_user).update(author=mp_user) + + # 3. 迁移分销员 + if hasattr(phone_user, 'distributor') and not hasattr(mp_user, 'distributor'): + dist = phone_user.distributor + dist.user = mp_user + dist.save() + + # 4. 迁移用户信息 + # 如果 mp_user 尚未设置昵称头像(新用户),则沿用 phone_user 的 + if not mp_user.nickname and phone_user.nickname: + mp_user.nickname = phone_user.nickname + if not mp_user.avatar_url and phone_user.avatar_url: + mp_user.avatar_url = phone_user.avatar_url + if mp_user.gender == 0 and phone_user.gender != 0: + mp_user.gender = phone_user.gender + + # 迁移关联的系统用户 (用于管理员权限等) + if phone_user.user and not mp_user.user: + mp_user.user = phone_user.user + phone_user.user = None + phone_user.save() + + # 标记拥有Web徽章 (如果旧用户是 Web 用户) + if phone_user.openid.startswith('web_') or phone_user.has_web_badge: + mp_user.has_web_badge = True + + # 更新手机号 + mp_user.phone_number = phone_number + mp_user.save() + + # 删除旧用户 + phone_user.delete() + user = mp_user + else: + # 同一个用户 + user = mp_user + + elif phone_user: + # 【绑定场景】: 只有手机号用户存在 + # 无论是否 Web 用户,只要 OpenID 不同,都更新为最新的 OpenID + user = phone_user + + if user.openid.startswith('web_'): + user.has_web_badge = True + + if user.openid != openid: + print(f"用户更换 OpenID: {user.openid} -> {openid}, Phone: {phone_number}") + user.openid = openid + user.save() + + elif mp_user: + # 【更新场景】: 只有小程序用户存在 -> 更新手机号 + user = mp_user + if phone_number: + # 检查手机号是否冲突 (理论上 phone_user 为 None 说明没有冲突) + user.phone_number = phone_number + user.save() + + else: + # 【新建场景】: 都不存在 -> 创建新用户 + if phone_number: + user = WeChatUser.objects.create(openid=openid) + user.phone_number = phone_number + user.save() + else: + # 严格限制:没有手机号无法注册 + # 如果用户既不是已存在的小程序用户,也未提供手机号,则拒绝注册/登录 + print(f"拒绝无手机号注册: OpenID={openid}") + return Response({'error': '请授权手机号进行登录', 'code': 'PHONE_REQUIRED'}, status=400) + + # 统一更新会话信息 (确保 user 对象存在) + if user and user.openid == openid: + user.session_key = session_key + user.unionid = unionid + + # 更新用户基本信息 (如果有传入) + if nickname: + user.nickname = nickname + elif not user.nickname: + # 默认昵称逻辑 (与 Web 端保持一致) + if user.phone_number: + user.nickname = f"User_{user.phone_number[-4:]}" + else: + user.nickname = f"WeChat_User_{user.openid[-4:]}" + + if avatar_url: + user.avatar_url = avatar_url + elif not user.avatar_url: + # 默认头像逻辑 + seed = user.phone_number or user.openid + user.avatar_url = f"https://api.dicebear.com/7.x/avataaars/svg?seed={seed}" + + if gender is not None: + user.gender = gender + if country: + user.country = country + if province: + user.province = province + if city: + user.city = city + + user.save() + + created = False # 简化处理 + + except Exception as e: + import traceback + traceback.print_exc() + # 确保 user 变量在异常发生时也存在,避免 UnboundLocalError + if 'user' not in locals(): + user = None + return Response({'error': f'Login failed: {str(e)}'}, status=500) + + # 生成 Token + if not user: + # 用户未注册且未提供手机号 + return Response({'error': 'User not registered', 'code': 'USER_NOT_FOUND'}, status=404) + + signer = TimestampSigner() + token = signer.sign(user.openid) + + # Use serializer to ensure all fields (including is_star, is_admin, etc.) are included + serializer = WeChatUserSerializer(user) + data = serializer.data + data.update({ + 'token': token, + 'is_new': created, + }) + + return Response(data) + +@extend_schema( + summary="更新微信用户信息", + request=WeChatUserSerializer, +) +@api_view(['POST']) +def update_user_info(request): + user = get_current_wechat_user(request) + if not user: + return Response({'error': 'Unauthorized'}, status=401) + + serializer = WeChatUserSerializer(user, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=400) + + +@extend_schema( + summary="手机号验证码登录 (Web端)", + description="通过手机号和验证码登录,支持Web端用户创建及与小程序用户合并", + request={ + 'application/json': { + 'type': 'object', + 'properties': { + 'phone_number': {'type': 'string', 'description': '手机号码'}, + 'code': {'type': 'string', 'description': '验证码'} + }, + 'required': ['phone_number', 'code'] + } + }, + responses={ + 200: OpenApiExample( + '成功', + value={ + 'token': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + 'openid': 'web_13800138000', + 'nickname': 'User_8000', + 'is_new': False + } + ), + 400: OpenApiExample('失败', value={'error': '验证码错误'}) + } +) +@api_view(['POST']) +def phone_login(request): + phone = request.data.get('phone_number') + code = request.data.get('code') + + if not phone or not code: + return Response({'error': '手机号和验证码不能为空'}, status=status.HTTP_400_BAD_REQUEST) + + # 验证验证码 (模拟环境允许 888888) + cache_key = f"sms_code_{phone}" + cached_code = cache.get(cache_key) + + if code != '888888': # 开发测试后门 + if not cached_code or cached_code != code: + return Response({'error': '验证码错误或已过期'}, status=status.HTTP_400_BAD_REQUEST) + + # 验证通过,清除验证码 + cache.delete(cache_key) + + # 查找或创建用户 + # 1. 查找是否已有绑定该手机号的用户 (可能是 MP 用户绑定了手机,或者是 Web 用户) + user = WeChatUser.objects.filter(phone_number=phone).first() + created = False + + if not user: + # 2. 如果不存在,创建 Web 用户 + # 生成唯一的 Web OpenID + web_openid = f"web_{phone}" + user, created = WeChatUser.objects.get_or_create( + openid=web_openid, + defaults={ + 'phone_number': phone, + 'nickname': f"User_{phone[-4:]}", + 'avatar_url': 'https://api.dicebear.com/7.x/avataaars/svg?seed=' + phone # 默认头像 + } + ) + + # 生成 Token + signer = TimestampSigner() + token = signer.sign(user.openid) + + # Use serializer to ensure all fields are included + serializer = WeChatUserSerializer(user) + data = serializer.data + data.update({ + 'token': token, + 'is_new': created, + }) + + return Response(data) + + +@extend_schema( + summary="绑定手机号 (小程序端)", + description="小程序用户绑定手机号,如果手机号已存在 Web 用户,则合并数据", + request={ + 'application/json': { + 'type': 'object', + 'properties': { + 'phone_number': {'type': 'string', 'description': '手机号码'}, + 'code': {'type': 'string', 'description': '验证码'} + }, + 'required': ['phone_number', 'code'] + } + }, + responses={ + 200: OpenApiExample('成功', value={'message': '绑定成功', 'merged': True}) + } +) +@api_view(['POST']) +def bind_phone(request): + current_user = get_current_wechat_user(request) + if not current_user: + return Response({'error': 'Unauthorized'}, status=401) + + phone = request.data.get('phone_number') + code = request.data.get('code') + + if not phone or not code: + return Response({'error': '手机号和验证码不能为空'}, status=status.HTTP_400_BAD_REQUEST) + + # 验证验证码 + cache_key = f"sms_code_{phone}" + cached_code = cache.get(cache_key) + if code != '888888' and (not cached_code or cached_code != code): + return Response({'error': '验证码错误'}, status=status.HTTP_400_BAD_REQUEST) + cache.delete(cache_key) + + # 检查手机号是否已被占用 + existing_user = WeChatUser.objects.filter(phone_number=phone).first() + + if existing_user: + if existing_user.id == current_user.id: + return Response({'message': '已绑定该手机号'}) + + # 发现冲突,需要合并 + # 策略:保留 current_user (MP User, with real OpenID),合并 existing_user (Phone User) 的数据 + # 无论 existing_user 是否是 Web 用户,都允许合并,以 current_user 为主(覆盖旧 OpenID) + + # 执行合并 + from django.db import transaction + with transaction.atomic(): + # 1. 迁移订单 + Order.objects.filter(wechat_user=existing_user).update(wechat_user=current_user) + # 2. 迁移社区 ActivitySignup + from community.models import ActivitySignup, Topic, Reply + ActivitySignup.objects.filter(user=existing_user).update(user=current_user) + # 3. 迁移 Topic + Topic.objects.filter(author=existing_user).update(author=current_user) + # 4. 迁移 Reply + Reply.objects.filter(author=existing_user).update(author=current_user) + # 5. 迁移 Distributor (如果旧用户注册了分销员,且新用户未注册) + if hasattr(existing_user, 'distributor') and not hasattr(current_user, 'distributor'): + dist = existing_user.distributor + dist.user = current_user + dist.save() + + # 6. 迁移用户信息 (如果新用户尚未设置,则使用旧用户的信息) + if not current_user.nickname and existing_user.nickname: + current_user.nickname = existing_user.nickname + if not current_user.avatar_url and existing_user.avatar_url: + current_user.avatar_url = existing_user.avatar_url + if current_user.gender == 0 and existing_user.gender != 0: + current_user.gender = existing_user.gender + + # 7. 迁移系统用户关联 + if existing_user.user and not current_user.user: + current_user.user = existing_user.user + existing_user.user = None + existing_user.save() + + # 8. 标记 Web 徽章 (如果旧用户是 Web 用户或已有徽章) + if existing_user.openid.startswith('web_') or existing_user.has_web_badge: + current_user.has_web_badge = True + + # 删除旧用户 + existing_user.delete() + + # 更新当前用户手机号 + current_user.phone_number = phone + current_user.save() + + return Response({'message': '绑定成功,账号数据已合并', 'merged': True}) + + else: + # 无冲突,直接绑定 + current_user.phone_number = phone + current_user.save() + return Response({'message': '绑定成功', 'merged': False}) + + +class DistributorViewSet(viewsets.GenericViewSet): + """ + 分销员接口 + """ + queryset = Distributor.objects.all() + serializer_class = DistributorSerializer + + @action(detail=False, methods=['post']) + def register(self, request): + user = get_current_wechat_user(request) + if not user: + return Response({'error': 'Unauthorized'}, status=401) + + if hasattr(user, 'distributor'): + return Response({'error': 'Already registered'}, status=400) + + # 检查是否有关联上级 (通过 invite_code) + parent = None + invite_code = request.data.get('invite_code') + if invite_code: + parent = Distributor.objects.filter(invite_code=invite_code).first() + + # 生成自己的邀请码 + my_invite_code = str(uuid.uuid4())[:8] + + distributor = Distributor.objects.create( + user=user, + parent=parent, + invite_code=my_invite_code, + status='pending' # 需要审核 + ) + + return Response(DistributorSerializer(distributor).data) + + @action(detail=False, methods=['get']) + def info(self, request): + user = get_current_wechat_user(request) + if not user: + return Response({'error': 'Unauthorized'}, status=401) + + if not hasattr(user, 'distributor'): + return Response({'error': 'Not a distributor'}, status=404) + + return Response(DistributorSerializer(user.distributor).data) + + @action(detail=False, methods=['post']) + def invite(self, request): + """生成小程序码""" + try: + user = get_current_wechat_user(request) + if not user or not hasattr(user, 'distributor'): + return Response({'error': 'Unauthorized'}, status=401) + + distributor = user.distributor + if distributor.qr_code_url: + # 检查文件是否真的存在 + try: + # 如果是本地存储,检查文件路径 + if distributor.qr_code_url.startswith(settings.MEDIA_URL): + file_path = distributor.qr_code_url.replace(settings.MEDIA_URL, '', 1) + if default_storage.exists(file_path): + return Response({'qr_code_url': distributor.qr_code_url}) + elif distributor.qr_code_url.startswith('http'): + # 远程 URL,假设有效 + return Response({'qr_code_url': distributor.qr_code_url}) + except Exception as e: + logger.warning(f"Error checking QR code existence: {e}") + + # 如果文件不存在,重置 URL 并重新生成 + distributor.qr_code_url = '' + distributor.save() + + # 确保有邀请码 + if not distributor.invite_code: + distributor.invite_code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=8)) + distributor.save() + + access_token = get_access_token() + if not access_token: + logger.error("Failed to get access token for invite generation") + return Response({'error': 'Failed to get access token'}, status=500) + + # 微信小程序码接口 B:适用于需要的码数量极多的业务场景 + url = f"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={access_token}" + data = { + "scene": distributor.invite_code, + "page": "pages/index/index", # 扫码落地页 + "width": 430, + "check_path": False, # 开发阶段不检查页面路径是否存在(因为可能还未发布) + "env_version": "develop" # 开发版 + } + + res = requests.post(url, json=data) + # 微信返回图片时 Content-Type 包含 image/jpeg 或 image/png + if res.status_code == 200 and 'image' in res.headers.get('Content-Type', ''): + file_name = f"distributor_qr_{distributor.invite_code}_{uuid.uuid4().hex[:6]}.png" + # 保存到 media/qr_codes 目录 + path = default_storage.save(f"qr_codes/{file_name}", ContentFile(res.content)) + qr_url = default_storage.url(path) + + distributor.qr_code_url = qr_url + distributor.save() + + return Response({'qr_code_url': qr_url}) + else: + # 如果是 JSON 错误信息 + logger.error(f"WeChat API error in invite: {res.status_code} - {res.text}") + try: + detail = res.json() + except: + detail = res.text + return Response({'error': 'WeChat API error', 'detail': detail}, status=500) + except Exception as e: + logger.error("Exception in invite view: %s", str(e), exc_info=True) + import traceback + traceback.print_exc() + return Response({'error': str(e), 'traceback': traceback.format_exc()}, status=500) + + @action(detail=False, methods=['post']) + def withdraw(self, request): + user = get_current_wechat_user(request) + if not user or not hasattr(user, 'distributor'): + return Response({'error': 'Unauthorized'}, status=401) + + amount = float(request.data.get('amount', 0)) + if amount <= 0: + return Response({'error': 'Invalid amount'}, status=400) + + distributor = user.distributor + if distributor.withdrawable_balance < amount: + return Response({'error': 'Insufficient balance'}, status=400) + + # 创建提现记录 + Withdrawal.objects.create( + distributor=distributor, + amount=amount, + status='pending' + ) + + # 扣减余额 + distributor.withdrawable_balance -= models.DecimalField(max_digits=12, decimal_places=2).to_python(amount) + distributor.save() + + return Response({'message': 'Withdrawal request submitted'}) + + @action(detail=False, methods=['get']) + def earnings(self, request): + """查看个人分销金额及明细""" + user = get_current_wechat_user(request) + if not user or not hasattr(user, 'distributor'): + return Response({'error': 'Unauthorized'}, status=401) + + distributor = user.distributor + logs = CommissionLog.objects.filter(distributor=distributor).order_by('-created_at') + + page = self.paginate_queryset(logs) + if page is not None: + serializer = CommissionLogSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = CommissionLogSerializer(logs, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def team(self, request): + """查看团队(二级分销情况)""" + user = get_current_wechat_user(request) + if not user or not hasattr(user, 'distributor'): + return Response({'error': 'Unauthorized'}, status=401) + + distributor = user.distributor + + # 直推下级 + children = Distributor.objects.filter(parent=distributor) + children_data = DistributorSerializer(children, many=True).data + + # 二级分销收益统计 + second_level_earnings = CommissionLog.objects.filter(distributor=distributor, level=2).aggregate(total=models.Sum('amount'))['total'] or 0 + + return Response({ + 'children_count': children.count(), + 'children': children_data, + 'second_level_earnings': second_level_earnings + }) + + @action(detail=False, methods=['get']) + def orders(self, request): + """查看分销订单""" + user = get_current_wechat_user(request) + if not user or not hasattr(user, 'distributor'): + return Response({'error': 'Unauthorized'}, status=401) + + distributor = user.distributor + # 查找我赚了钱的订单 + commission_logs = CommissionLog.objects.filter(distributor=distributor).select_related('order') + order_ids = commission_logs.values_list('order_id', flat=True) + orders = Order.objects.filter(id__in=order_ids).order_by('-created_at') + + page = self.paginate_queryset(orders) + if page is not None: + serializer = OrderSerializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = OrderSerializer(orders, many=True) + return Response(serializer.data) + +class WeChatUserViewSet(viewsets.ReadOnlyModelViewSet): + """ + 微信用户视图集 + """ + queryset = WeChatUser.objects.all() + serializer_class = WeChatUserSerializer + + @action(detail=False, methods=['get']) + def me(self, request): + """获取当前用户信息""" + user = get_current_wechat_user(request) + if not user: + return Response({'error': 'Unauthorized'}, status=401) + serializer = self.get_serializer(user) + return Response(serializer.data) + + @action(detail=False, methods=['get']) + def stars(self, request): + """ + 获取明星技术用户列表 + """ + stars = WeChatUser.objects.filter(is_star=True).order_by('order', '-created_at') + serializer = self.get_serializer(stars, many=True) + return Response(serializer.data) + + @action(detail=False, methods=['get'], url_path='paid-items') + def paid_items(self, request): + """ + 获取当前用户已购买的项目(硬件、课程、服务) + 用于论坛发帖时关联 + """ + user = get_current_wechat_user(request) + if not user: + return Response({'error': 'Unauthorized'}, status=401) + + # 1. 硬件 (ESP32Config) + paid_orders = Order.objects.filter(wechat_user=user, status__in=['paid', 'shipped']) + config_ids = paid_orders.exclude(config__isnull=True).values_list('config_id', flat=True).distinct() + configs = ESP32Config.objects.filter(id__in=config_ids) + + # 2. 课程 (VCCourse) + course_ids = paid_orders.exclude(course__isnull=True).values_list('course_id', flat=True).distinct() + courses = VCCourse.objects.filter(id__in=course_ids) + + # 3. 服务 (Service) + # 暂时没有强关联 WeChatUser 的 ServiceOrder,如果有 phone_number 匹配逻辑可在此添加 + # 简单起见,暂不返回服务,或基于 phone_number 匹配 + service_orders = ServiceOrder.objects.filter(phone_number=user.phone_number, status='paid') + service_ids = service_orders.values_list('service_id', flat=True).distinct() + services = Service.objects.filter(id__in=service_ids) + + return Response({ + 'configs': ESP32ConfigSerializer(configs, many=True).data, + 'courses': VCCourseSerializer(courses, many=True).data, + 'services': ServiceSerializer(services, many=True).data + }) + +@extend_schema( + summary="上传图片", + description="上传图片文件,返回图片URL", + request={ + 'multipart/form-data': { + 'type': 'object', + 'properties': { + 'file': {'type': 'string', 'format': 'binary'} + }, + 'required': ['file'] + } + }, + responses={ + 200: OpenApiExample('成功', value={'url': 'http://.../media/uploads/xxx.jpg'}) + } +) +@api_view(['POST']) +@parser_classes([MultiPartParser, FormParser]) +def upload_image(request): + file_obj = request.FILES.get('file') + if not file_obj: + return Response({'error': 'No file uploaded'}, status=400) + + # 验证文件类型 + if not file_obj.content_type.startswith('image/'): + return Response({'error': 'File is not an image'}, status=400) + + # 生成唯一文件名 + ext = os.path.splitext(file_obj.name)[1] + filename = f"uploads/avatars/{uuid.uuid4()}{ext}" + + # 保存文件 + path = default_storage.save(filename, ContentFile(file_obj.read())) + + # 获取完整URL + # 注意:如果使用了云存储,url会自动包含域名;如果是本地存储,可能需要手动拼接 + file_url = default_storage.url(path) + + # 确保 URL 是完整的 (如果是相对路径,拼接当前 host) + if not file_url.startswith('http'): + file_url = request.build_absolute_uri(file_url) + + return Response({'url': file_url}) + diff --git a/backend/start_judge_system.sh b/backend/start_judge_system.sh new file mode 100755 index 0000000..c5503d4 --- /dev/null +++ b/backend/start_judge_system.sh @@ -0,0 +1,18 @@ +#!/bin/bash +echo "Starting Judge System..." + +# 激活虚拟环境 (如果有) +if [ -d "venv" ]; then + source venv/bin/activate +fi + +# 安装依赖 +pip install -r requirements.txt + +# 迁移数据库 +python manage.py makemigrations +python manage.py migrate + +# 启动 Django 开发服务器 +echo "Server running at http://127.0.0.1:8000/competition/admin/" +python manage.py runserver 0.0.0.0:8000 diff --git a/backend/uploads/avatars/474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG b/backend/uploads/avatars/474713be-b897-40e6-80ce-d5ce2afdf6e1.PNG new file mode 100644 index 0000000000000000000000000000000000000000..c44d06ec927b7966adc714bda312d9316af73699 GIT binary patch literal 36020 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%0Ae|BvA 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 literal 0 HcmV?d00001 diff --git a/deploy_market_page 2.sh b/deploy_market_page 2.sh new file mode 100644 index 0000000..73fc6d0 --- /dev/null +++ b/deploy_market_page 2.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# 定义关键变量,方便后续维护修改 +TARGET_DIR="~/data/dev/market_page" +SUDO_PASSWORD="123quant-speed" + +# 脚本执行出错时立即退出 +set -e + +# 1. 切换到目标目录(先解析 ~ 为实际家目录) +echo "===== 切换到目标目录: $TARGET_DIR =====" +RESOLVED_DIR=$(eval echo $TARGET_DIR) +cd $RESOLVED_DIR || { + echo "错误:目录 $RESOLVED_DIR 不存在!" + exit 1 +} + +# 2. 停止并移除 Docker 容器(自动输入 sudo 密码) +echo -e "\n===== 停止 Docker 容器 =====" +echo $SUDO_PASSWORD | sudo -S docker compose down + +# 3. 删除 Docker 镜像(说明:这里默认删除 compose 关联的镜像,也可指定镜像名) +echo -e "\n===== 删除 Docker 镜像 =====" +# 方式1:删除 compose.yml 中定义的所有镜像(推荐) +echo $SUDO_PASSWORD | sudo -S docker compose down --rmi all +# 方式2:如果你想删除指定镜像,替换上面这行(示例,需修改为你的镜像名) +# echo $SUDO_PASSWORD | sudo -S docker rmi -f your-image-name:tag + +# 4. 拉取 Git 最新代码 +echo -e "\n===== 拉取 Git 代码 =====" +git pull || { + echo "警告:Git pull 失败(可能是本地有未提交的修改),脚本继续执行..." +} + +# 5. 重新启动 Docker 容器(后台运行) +echo -e "\n===== 启动 Docker 容器 =====" +echo $SUDO_PASSWORD | sudo -S docker compose up -d + +echo -e "\n===== 操作完成!=====" \ No newline at end of file diff --git a/deploy_market_page.sh b/deploy_market_page.sh new file mode 100644 index 0000000..73fc6d0 --- /dev/null +++ b/deploy_market_page.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +# 定义关键变量,方便后续维护修改 +TARGET_DIR="~/data/dev/market_page" +SUDO_PASSWORD="123quant-speed" + +# 脚本执行出错时立即退出 +set -e + +# 1. 切换到目标目录(先解析 ~ 为实际家目录) +echo "===== 切换到目标目录: $TARGET_DIR =====" +RESOLVED_DIR=$(eval echo $TARGET_DIR) +cd $RESOLVED_DIR || { + echo "错误:目录 $RESOLVED_DIR 不存在!" + exit 1 +} + +# 2. 停止并移除 Docker 容器(自动输入 sudo 密码) +echo -e "\n===== 停止 Docker 容器 =====" +echo $SUDO_PASSWORD | sudo -S docker compose down + +# 3. 删除 Docker 镜像(说明:这里默认删除 compose 关联的镜像,也可指定镜像名) +echo -e "\n===== 删除 Docker 镜像 =====" +# 方式1:删除 compose.yml 中定义的所有镜像(推荐) +echo $SUDO_PASSWORD | sudo -S docker compose down --rmi all +# 方式2:如果你想删除指定镜像,替换上面这行(示例,需修改为你的镜像名) +# echo $SUDO_PASSWORD | sudo -S docker rmi -f your-image-name:tag + +# 4. 拉取 Git 最新代码 +echo -e "\n===== 拉取 Git 代码 =====" +git pull || { + echo "警告:Git pull 失败(可能是本地有未提交的修改),脚本继续执行..." +} + +# 5. 重新启动 Docker 容器(后台运行) +echo -e "\n===== 启动 Docker 容器 =====" +echo $SUDO_PASSWORD | sudo -S docker compose up -d + +echo -e "\n===== 操作完成!=====" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..73bdca8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + backend: + build: ./backend + # 使用 gunicorn 替代 runserver,提高稳定性,并捕获标准输出1 + command: sh -c "python manage.py collectstatic --noinput && python manage.py migrate && gunicorn --bind 0.0.0.0:8000 --access-logfile - --error-logfile - config.wsgi:application" + volumes: + - ./backend:/app + - ./backend/media:/app/media + ports: + - "8000:8000" + environment: + - DB_NAME=market + - DB_USER=market + - DB_PASSWORD=123market + - DB_HOST=6.6.6.66 + - DB_PORT=5432 + + frontend: + build: + context: ./frontend + args: + - VITE_API_URL=/api + # volumes: + # - ./frontend:/app + # - /app/node_modules + ports: + - "15173:15173" + environment: + - VITE_API_URL=http://localhost:8000/api + depends_on: + - backend diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..4b683af --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,29 @@ +# Use an official Node runtime as a parent image +FROM node:22-alpine + +# Set working directory +WORKDIR /app + +# Install build dependencies for native modules +RUN apk add --no-cache autoconf automake libtool make g++ zlib-dev nasm python3 + +# Install dependencies +COPY package.json package-lock.json* ./ +RUN npm install --registry=https://registry.npmmirror.com + +# 复制项目文件 +COPY . . + +# 接收构建参数 +ARG VITE_API_URL=/api +# 设置环境变量供构建时使用 +ENV VITE_API_URL=$VITE_API_URL + +# 构建生产环境代码 +RUN npm run build + +# 暴露应用运行的端口 +EXPOSE 15173 + +# 启动应用 (Preview 模式) +CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "15173"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..18bc70e --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,16 @@ +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..4fa125d --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..bbbb7df --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + 评分系统 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e589f93 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,57 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.5.0", + "@uiw/react-md-editor": "^4.0.11", + "antd": "^6.2.2", + "axios": "^1.13.4", + "framer-motion": "^12.29.2", + "github-markdown-css": "^5.9.0", + "jszip": "^3.10.1", + "qrcode.react": "^4.2.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.13.0", + "react-syntax-highlighter": "^16.1.0", + "rehype-katex": "^7.0.1", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0", + "three": "^0.182.0" + }, + "devDependencies": { + "@ant-design/icons": "^6.1.0", + "@eslint/js": "^9.39.1", + "@storybook/addon-essentials": "^8.6.4", + "@storybook/addon-interactions": "^8.6.4", + "@storybook/blocks": "^8.6.4", + "@storybook/react": "^8.6.4", + "@storybook/react-vite": "^8.6.4", + "@storybook/test": "^8.6.4", + "@tanstack/react-query": "^5.90.21", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "canvas-confetti": "^1.9.4", + "classnames": "^2.5.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "less": "^4.5.1", + "storybook": "^8.6.4", + "vite": "^6.0.0", + "vite-plugin-imagemin": "^0.6.1" + } +} diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..5e440091c27eeb167435199f71b3dd8ac1bf9701 GIT binary patch literal 49604 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 literal 0 HcmV?d00001 diff --git a/frontend/public/shouye.png b/frontend/public/shouye.png new file mode 100644 index 0000000000000000000000000000000000000000..7fc17840df2ded39831cda095f07591c703ddb46 GIT binary patch literal 213769 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 ei5A!LG1BUc?}?BiMn&sIzpQJ4)9R=AuElSS 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

+ +
+ ); +}; + +export default CodeBlock; diff --git a/frontend/src/components/CreateTopicModal.jsx b/frontend/src/components/CreateTopicModal.jsx new file mode 100644 index 0000000..38627ec --- /dev/null +++ b/frontend/src/components/CreateTopicModal.jsx @@ -0,0 +1,286 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Form, Input, Button, message, Upload, Select } from 'antd'; +import { UploadOutlined } from '@ant-design/icons'; +import { createTopic, updateTopic, uploadMedia, getMyPaidItems } from '../api'; +import MDEditor from '@uiw/react-md-editor'; +import rehypeKatex from 'rehype-katex'; +import remarkMath from 'remark-math'; +import 'katex/dist/katex.css'; + +const { Option } = Select; + +const CreateTopicModal = ({ visible, onClose, onSuccess, initialValues, isEditMode, topicId }) => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [paidItems, setPaidItems] = useState({ configs: [], courses: [], services: [] }); + const [uploading, setUploading] = useState(false); + const [mediaIds, setMediaIds] = useState([]); + // eslint-disable-next-line no-unused-vars + const [mediaList, setMediaList] = useState([]); // Store uploaded media details for preview + const [content, setContent] = useState(""); + + useEffect(() => { + if (visible) { + fetchPaidItems(); + if (isEditMode && initialValues) { + // Edit Mode: Populate form with initial values + form.setFieldsValue({ + title: initialValues.title, + category: initialValues.category, + }); + setContent(initialValues.content); + form.setFieldValue('content', initialValues.content); + + // Handle related item + let relatedVal = null; + if (initialValues.related_product) relatedVal = `config_${initialValues.related_product.id || initialValues.related_product}`; + else if (initialValues.related_course) relatedVal = `course_${initialValues.related_course.id || initialValues.related_course}`; + else if (initialValues.related_service) relatedVal = `service_${initialValues.related_service.id || initialValues.related_service}`; + + if (relatedVal) form.setFieldValue('related_item', relatedVal); + + // Note: We start with empty *new* media IDs. + // Existing media is embedded in content or stored in DB, we don't need to re-upload or track them here unless we want to delete them (which is complex). + // For now, we just allow adding NEW media. + setMediaIds([]); + setMediaList([]); + } else { + // Create Mode: Reset form + setMediaIds([]); + setMediaList([]); + setContent(""); + form.resetFields(); + form.setFieldsValue({ content: "", category: 'discussion' }); + } + } + }, [visible, isEditMode, initialValues, form]); + + const fetchPaidItems = async () => { + try { + const res = await getMyPaidItems(); + setPaidItems(res.data); + } catch (error) { + console.error("Failed to fetch paid items", error); + } + }; + + const handleUpload = async (file) => { + const formData = new FormData(); + formData.append('file', file); + // 默认为 image,如果需要支持视频需根据 file.type 判断 + formData.append('media_type', file.type.startsWith('video') ? 'video' : 'image'); + + setUploading(true); + try { + const res = await uploadMedia(formData); + // 记录上传的媒体 ID + if (res.data.id) { + setMediaIds(prev => [...prev, res.data.id]); + } + + // 确保 URL 是完整的 + // 由于后端现在是转发到外部OSS,返回的URL通常是完整的,但也可能是相对的,这里统一处理 + let url = res.data.file; + + // 处理反斜杠问题(防止 Windows 路径风格影响 URL) + if (url) { + url = url.replace(/\\/g, '/'); + } + + if (url && !url.startsWith('http')) { + // 如果返回的是相对路径,拼接 API URL 或 Base URL + const baseURL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + // 移除 baseURL 末尾的 /api 或 / + const host = baseURL.replace(/\/api\/?$/, ''); + // 确保 url 以 / 开头 + if (!url.startsWith('/')) url = '/' + url; + url = `${host}${url}`; + } + + // 清理 URL 中的双斜杠 (除协议头外) + url = url.replace(/([^:]\/)\/+/g, '$1'); + + // Add to media list for preview + setMediaList(prev => [...prev, { + id: res.data.id, + url: url, + type: file.type.startsWith('video') ? 'video' : 'image', + name: file.name + }]); + + // 插入到编辑器 + const insertText = file.type.startsWith('video') + ? `\n\n` + : `\n![${file.name}](${url})\n`; + + const newContent = content + insertText; + setContent(newContent); + form.setFieldsValue({ content: newContent }); + + message.success('上传成功'); + } catch (error) { + console.error(error); + message.error('上传失败'); + } finally { + setUploading(false); + } + return false; // 阻止默认上传行为 + }; + + const handleSubmit = async (values) => { + setLoading(true); + try { + // 处理关联项目 ID (select value format: "type_id") + const relatedValue = values.related_item; + // Use content state instead of form value to ensure consistency + const payload = { ...values, content: content, media_ids: mediaIds }; + delete payload.related_item; + + if (relatedValue) { + const [type, id] = relatedValue.split('_'); + if (type === 'config') payload.related_product = id; + if (type === 'course') payload.related_course = id; + if (type === 'service') payload.related_service = id; + } else { + // If cleared, set to null + payload.related_product = null; + payload.related_course = null; + payload.related_service = null; + } + + if (isEditMode && topicId) { + await updateTopic(topicId, payload); + message.success('修改成功'); + } else { + const res = await createTopic(payload); + const topic = res.data || res; + if (topic.status === 'pending') { + message.info('提交成功,请等待管理员审核'); + } else { + message.success('发布成功!'); + } + } + + form.resetFields(); + if (onSuccess) onSuccess(); + onClose(); + } catch (error) { + console.error(error); + message.error((isEditMode ? '修改' : '发布') + '失败: ' + (error.response?.data?.detail || '网络错误')); + } finally { + setLoading(false); + } + }; + + return ( + +
+ + + + +
+ + + + + + + +
+ + +
+
+ + + +
+ + { + setContent(val); + form.setFieldsValue({ content: val }); + }} + height={400} + previewOptions={{ + rehypePlugins: [[rehypeKatex]], + remarkPlugins: [[remarkMath]], + }} + /> +
+
+ + +
+ + +
+
+
+
+ ); +}; + +export default CreateTopicModal; \ No newline at end of file diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..ec15b60 --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,296 @@ +import React, { useState, useEffect } from 'react'; +import { Layout as AntLayout, Menu, ConfigProvider, theme, Drawer, Button, Avatar, Dropdown } from 'antd'; +import { HomeOutlined, MenuOutlined, TrophyOutlined, CalendarOutlined, BookOutlined, UserOutlined, LogoutOutlined, WechatOutlined } from '@ant-design/icons'; +import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; +import ParticleBackground from './ParticleBackground'; +import LoginModal from './LoginModal'; +import ProfileModal from './ProfileModal'; +import { motion, AnimatePresence } from 'framer-motion'; +import { useAuth } from '../context/AuthContext'; + +const { Header, Content, Footer } = AntLayout; + +const Layout = ({ children }) => { + const navigate = useNavigate(); + const location = useLocation(); + const [searchParams] = useSearchParams(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [profileVisible, setProfileVisible] = useState(false); + + const { user, login, logout, loginModalVisible, showLoginModal, hideLoginModal } = useAuth(); + + // 全局监听并持久化 ref 参数 + useEffect(() => { + const ref = searchParams.get('ref'); + if (ref) { + console.log('[Layout] Capturing sales ref code:', ref); + localStorage.setItem('ref_code', ref); + } + }, [searchParams]); + + const handleLogout = () => { + logout(); + navigate('/'); + }; + + const userMenu = { + items: [ + { + key: 'profile', + label: '个人设置', + icon: , + onClick: () => setProfileVisible(true) + }, + { + key: 'logout', + label: '退出登录', + icon: , + onClick: handleLogout + } + ] + }; + + const items = [ + { + key: '/', + icon: , + label: '首页', + }, + { + key: '/competitions', + icon: , + label: '赛事中心', + }, + { + key: '/activities', + icon: , + label: '系列活动', + }, + { + key: '/courses', + icon: , + label: '课程培训', + }, + { + key: '/my-orders', + icon: , + label: '我的', + }, + ]; + + const handleMenuClick = (key) => { + navigate(key); + setMobileMenuOpen(false); + }; + + return ( + + + +
+
+ navigate('/')} + > + Quant Speed Logo + + + {/* Desktop Menu */} +
+ handleMenuClick(e.key)} + style={{ + background: 'transparent', + borderBottom: 'none', + display: 'flex', + justifyContent: 'flex-end', + minWidth: '400px', + marginRight: '20px' + }} + /> + + {user ? ( +
+ {/* 小程序图标状态 */} + + + +
+ } style={{ marginRight: 8 }} /> + {user.nickname} +
+
+
+ ) : ( + + )} +
+ + + {/* Mobile Menu Button */} +
+
+ + {/* Mobile Drawer Menu */} + 导航菜单} + placement="right" + onClose={() => setMobileMenuOpen(false)} + open={mobileMenuOpen} + styles={{ body: { padding: 0, background: '#111' }, header: { background: '#111', borderBottom: '1px solid #333' }, wrapper: { width: 250 } }} + > +
+ {user ? ( +
+ } + size="large" + style={{ marginBottom: 10, cursor: 'pointer' }} + onClick={() => { setProfileVisible(true); setMobileMenuOpen(false); }} + /> +
{ setProfileVisible(true); setMobileMenuOpen(false); }} style={{ cursor: 'pointer' }}> + {user.nickname} +
+ +
+ ) : ( + + )} +
+
handleMenuClick(e.key)} + style={{ background: 'transparent', borderRight: 'none' }} + /> + + + login(userData)} + /> + + setProfileVisible(false)} + /> + + +
+ + + {children} + + +
+
+ +
+ Quant Speed AI Hardware ©{new Date().getFullYear()} Created by Quant Speed Tech +
+ + + ); +}; + +export default Layout; diff --git a/frontend/src/components/LoginModal.jsx b/frontend/src/components/LoginModal.jsx new file mode 100644 index 0000000..057f99c --- /dev/null +++ b/frontend/src/components/LoginModal.jsx @@ -0,0 +1,123 @@ +import React, { useState } from 'react'; +import { Modal, Form, Input, Button, message } from 'antd'; +import { UserOutlined, LockOutlined, MobileOutlined } from '@ant-design/icons'; +import { sendSms, phoneLogin } from '../api'; + +const LoginModal = ({ visible, onClose, onLoginSuccess }) => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [countdown, setCountdown] = useState(0); + + const handleSendCode = async () => { + try { + const phone = form.getFieldValue('phone_number'); + if (!phone) { + message.error('请输入手机号'); + return; + } + + // 简单的手机号校验 + if (!/^1[3-9]\d{9}$/.test(phone)) { + message.error('请输入有效的手机号'); + return; + } +// + await sendSms({ phone_number: phone }); + message.success('验证码已发送'); + + setCountdown(60); + const timer = setInterval(() => { + setCountdown((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + + } catch (error) { + console.error(error); + message.error('发送失败: ' + (error.response?.data?.error || '网络错误')); + } + }; + + const handleSubmit = async (values) => { + setLoading(true); + try { + const res = await phoneLogin(values); + + message.success('登录成功'); + onLoginSuccess(res.data); + onClose(); + } catch (error) { + console.error(error); + message.error('登录失败: ' + (error.response?.data?.error || '网络错误')); + } finally { + setLoading(false); + } + }; + + return ( + +
+ + } + placeholder="手机号码" + size="large" + /> + + + +
+ } + placeholder="验证码" + size="large" + /> + +
+
+ + + + + +
+ 未注册的手机号验证后将自动创建账号
+ 已在小程序绑定的手机号将自动同步身份 +
+
+
+ ); +}; + +export default LoginModal; diff --git a/frontend/src/components/ModelViewer.jsx b/frontend/src/components/ModelViewer.jsx new file mode 100644 index 0000000..0eb76da --- /dev/null +++ b/frontend/src/components/ModelViewer.jsx @@ -0,0 +1,218 @@ +import React, { Suspense, useState, useEffect } from 'react'; +import { Canvas, useLoader } from '@react-three/fiber'; +import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'; +import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'; +import { OrbitControls, Stage, useProgress, Environment, ContactShadows } from '@react-three/drei'; +import { Spin } from 'antd'; +import JSZip from 'jszip'; +import * as THREE from 'three'; + +class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + console.error("3D Model Viewer Error:", error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+ 3D 模型加载失败 +
+ ); + } + + return this.props.children; + } +} + +const Model = ({ objPath, mtlPath, scale = 1 }) => { + // If mtlPath is provided, load materials first + const materials = mtlPath ? useLoader(MTLLoader, mtlPath) : null; + + const obj = useLoader(OBJLoader, objPath, (loader) => { + if (materials) { + materials.preload(); + loader.setMaterials(materials); + } + }); + + const clone = obj.clone(); + return ; +}; + +const LoadingOverlay = () => { + const { progress, active } = useProgress(); + if (!active) return null; + + return ( +
+
+ +
+ {progress.toFixed(0)}% Loading +
+
+
+ ); +}; + +const ModelViewer = ({ objPath, mtlPath, scale = 1, autoRotate = true }) => { + const [paths, setPaths] = useState(null); + const [unzipping, setUnzipping] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let isMounted = true; + const blobUrls = []; + + const loadPaths = async () => { + if (!objPath) return; + + // 如果是 zip 文件 + if (objPath.toLowerCase().endsWith('.zip')) { + setUnzipping(true); + setError(null); + try { + const response = await fetch(objPath); + const arrayBuffer = await response.arrayBuffer(); + const zip = await JSZip.loadAsync(arrayBuffer); + + let extractedObj = null; + let extractedMtl = null; + const fileMap = {}; + + // 1. 提取所有文件并创建 Blob URL 映射 + for (const [filename, file] of Object.entries(zip.files)) { + if (file.dir) continue; + + const content = await file.async('blob'); + const url = URL.createObjectURL(content); + blobUrls.push(url); + + // 记录文件名到 URL 的映射,用于后续材质引用图片等情况 + const baseName = filename.split('/').pop(); + fileMap[baseName] = url; + + if (filename.toLowerCase().endsWith('.obj')) { + extractedObj = url; + } else if (filename.toLowerCase().endsWith('.mtl')) { + extractedMtl = url; + } + } + + if (isMounted) { + if (extractedObj) { + setPaths({ obj: extractedObj, mtl: extractedMtl }); + } else { + setError('压缩包内未找到 .obj 模型文件'); + } + } + } catch (err) { + console.error('Error unzipping model:', err); + if (isMounted) setError('加载压缩包失败'); + } finally { + if (isMounted) setUnzipping(false); + } + } else { + // 普通路径 + setPaths({ obj: objPath, mtl: mtlPath }); + } + }; + + loadPaths(); + + return () => { + isMounted = false; + // 清理 Blob URL 释放内存 + blobUrls.forEach(url => URL.revokeObjectURL(url)); + }; + }, [objPath, mtlPath]); + + if (unzipping) { + return ( +
+ +
正在解压 3D 资源...
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!paths) return null; + + return ( +
+ + + + + + + + + + + + + + + + + +
+ ); +}; + +export default ModelViewer; diff --git a/frontend/src/components/ParticleBackground.jsx b/frontend/src/components/ParticleBackground.jsx new file mode 100644 index 0000000..6bb17a1 --- /dev/null +++ b/frontend/src/components/ParticleBackground.jsx @@ -0,0 +1,174 @@ +import React, { useEffect, useRef } from 'react'; + +const ParticleBackground = () => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + let animationFrameId; + + const resizeCanvas = () => { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + }; + + window.addEventListener('resize', resizeCanvas); + resizeCanvas(); + + const particles = []; + const particleCount = 100; + const meteors = []; + const meteorCount = 8; + + class Particle { + constructor() { + this.x = Math.random() * canvas.width; + this.y = Math.random() * canvas.height; + this.vx = (Math.random() - 0.5) * 0.5; + this.vy = (Math.random() - 0.5) * 0.5; + this.size = Math.random() * 2; + this.color = Math.random() > 0.5 ? 'rgba(0, 185, 107, ' : 'rgba(0, 240, 255, '; // Green or Blue + } + + update() { + this.x += this.vx; + this.y += this.vy; + + if (this.x < 0 || this.x > canvas.width) this.vx *= -1; + if (this.y < 0 || this.y > canvas.height) this.vy *= -1; + } + + draw() { + ctx.beginPath(); + ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); + ctx.fillStyle = this.color + Math.random() * 0.5 + ')'; + ctx.fill(); + } + } + + class Meteor { + constructor() { + this.reset(); + } + + reset() { + this.x = Math.random() * canvas.width * 1.5; // Start further right + this.y = Math.random() * -canvas.height; // Start further above + this.vx = -(Math.random() * 5 + 5); // Faster + this.vy = Math.random() * 5 + 5; // Faster + this.len = Math.random() * 150 + 150; // Longer trail + this.color = Math.random() > 0.5 ? 'rgba(0, 185, 107, ' : 'rgba(0, 240, 255, '; + this.opacity = 0; + this.maxOpacity = Math.random() * 0.5 + 0.2; + this.wait = Math.random() * 300; // Random delay before showing up + } + + update() { + if (this.wait > 0) { + this.wait--; + return; + } + + this.x += this.vx; + this.y += this.vy; + + if (this.opacity < this.maxOpacity) { + this.opacity += 0.02; + } + + if (this.x < -this.len || this.y > canvas.height + this.len) { + this.reset(); + } + } + + draw() { + if (this.wait > 0) return; + + const tailX = this.x - this.vx * (this.len / 15); + const tailY = this.y - this.vy * (this.len / 15); + + const gradient = ctx.createLinearGradient(this.x, this.y, tailX, tailY); + gradient.addColorStop(0, this.color + this.opacity + ')'); + gradient.addColorStop(0.1, this.color + (this.opacity * 0.5) + ')'); + gradient.addColorStop(1, this.color + '0)'); + + ctx.save(); + + // Add glow effect + ctx.shadowBlur = 8; + ctx.shadowColor = this.color.replace('rgba', 'rgb').replace(', ', ')'); + + ctx.beginPath(); + ctx.strokeStyle = gradient; + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.moveTo(this.x, this.y); + ctx.lineTo(tailX, tailY); + ctx.stroke(); + + // Add a bright head + ctx.beginPath(); + ctx.fillStyle = '#fff'; + ctx.arc(this.x, this.y, 1, 0, Math.PI * 2); + ctx.fill(); + + ctx.restore(); + } + } + + for (let i = 0; i < particleCount; i++) { + particles.push(new Particle()); + } + + for (let i = 0; i < meteorCount; i++) { + meteors.push(new Meteor()); + } + + const animate = () => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Draw meteors first (in background) + meteors.forEach(m => { + m.update(); + m.draw(); + }); + + // Draw connecting lines + ctx.lineWidth = 0.5; + for (let i = 0; i < particleCount; i++) { + for (let j = i; j < particleCount; j++) { + const dx = particles[i].x - particles[j].x; + const dy = particles[i].y - particles[j].y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < 100) { + ctx.beginPath(); + ctx.strokeStyle = `rgba(100, 255, 218, ${1 - distance / 100})`; + ctx.moveTo(particles[i].x, particles[i].y); + ctx.lineTo(particles[j].x, particles[j].y); + ctx.stroke(); + } + } + } + + particles.forEach(p => { + p.update(); + p.draw(); + }); + + animationFrameId = requestAnimationFrame(animate); + }; + + animate(); + + return () => { + window.removeEventListener('resize', resizeCanvas); + cancelAnimationFrame(animationFrameId); + }; + }, []); + + return ; +}; + +export default ParticleBackground; diff --git a/frontend/src/components/ProfileModal.jsx b/frontend/src/components/ProfileModal.jsx new file mode 100644 index 0000000..24f730d --- /dev/null +++ b/frontend/src/components/ProfileModal.jsx @@ -0,0 +1,124 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Form, Input, Upload, Button, message, Avatar } from 'antd'; +import { UserOutlined, UploadOutlined, LoadingOutlined } from '@ant-design/icons'; +import { useAuth } from '../context/AuthContext'; +import { updateUserInfo, uploadUserAvatar } from '../api'; + +const ProfileModal = ({ visible, onClose }) => { + const { user, updateUser } = useAuth(); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [uploading, setUploading] = useState(false); + const [avatarUrl, setAvatarUrl] = useState(''); + + useEffect(() => { + if (visible && user) { + form.setFieldsValue({ + nickname: user.nickname, + }); + setAvatarUrl(user.avatar_url); + } + }, [visible, user, form]); + + const handleUpload = async (file) => { + const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'; + if (!isJpgOrPng) { + message.error('You can only upload JPG/PNG file!'); + return Upload.LIST_IGNORE; + } + const isLt2M = file.size / 1024 / 1024 < 2; + if (!isLt2M) { + message.error('Image must smaller than 2MB!'); + return Upload.LIST_IGNORE; + } + + const formData = new FormData(); + formData.append('file', file); + + setUploading(true); + try { + const res = await uploadUserAvatar(formData); + if (res.data.success) { + setAvatarUrl(res.data.file_url); + message.success('头像上传成功'); + } else { + message.error('头像上传失败: ' + (res.data.message || '未知错误')); + } + } catch (error) { + console.error('Upload failed:', error); + message.error('头像上传失败'); + } finally { + setUploading(false); + } + return false; // Prevent default auto upload + }; + + const handleOk = async () => { + try { + const values = await form.validateFields(); + setLoading(true); + + const updateData = { + nickname: values.nickname, + avatar_url: avatarUrl + }; + + const res = await updateUserInfo(updateData); + updateUser(res.data); + message.success('个人信息更新成功'); + onClose(); + } catch (error) { + console.error('Update failed:', error); + message.error('更新失败'); + } finally { + setLoading(false); + } + }; + + return ( + +
+ +
+ } + /> + + + +
+
+ + + + +
+
+ ); +}; + +export default ProfileModal; diff --git a/frontend/src/components/activity/ActivityCard.jsx b/frontend/src/components/activity/ActivityCard.jsx new file mode 100644 index 0000000..67f1876 --- /dev/null +++ b/frontend/src/components/activity/ActivityCard.jsx @@ -0,0 +1,101 @@ + +import React, { useState, useRef, useLayoutEffect } from 'react'; +import { motion } from 'framer-motion'; +import { CalendarOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import styles from './activity.module.less'; +import { hoverScale } from '../../animation'; + +const ActivityCard = ({ activity }) => { + const navigate = useNavigate(); + const [isLoaded, setIsLoaded] = useState(false); + const [hasError, setHasError] = useState(false); + const imgRef = useRef(null); + + const handleCardClick = () => { + navigate(`/activity/${activity.id}`); + }; + + const getStatus = (startTime) => { + const now = new Date(); + const start = new Date(startTime); + if (now < start) return '即将开始'; + return '报名中'; + }; + + const formatDate = (dateStr) => { + if (!dateStr) return 'TBD'; + const date = new Date(dateStr); + return date.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' }); + }; + + const imgSrc = hasError + ? 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=600&h=400&fit=crop' + : (activity.display_banner_url || activity.banner_url || activity.cover_image || 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?w=600&h=400&fit=crop'); + + // Check if image is already loaded (cached) to prevent flashing + useLayoutEffect(() => { + if (imgRef.current && imgRef.current.complete) { + setIsLoaded(true); + } + }, [imgSrc]); + + return ( + +
+ {/* Placeholder Background - Always visible behind the image */} +
+ + {activity.title} setIsLoaded(true)} + onError={() => { + setHasError(true); + setIsLoaded(true); + }} + loading="lazy" + /> +
+
+ {activity.status || getStatus(activity.start_time)} +
+

{activity.title}

+
+ + {formatDate(activity.start_time)} +
+
+
+ + ); +}; + +export default ActivityCard; diff --git a/frontend/src/components/activity/ActivityCard.stories.jsx b/frontend/src/components/activity/ActivityCard.stories.jsx new file mode 100644 index 0000000..afd33af --- /dev/null +++ b/frontend/src/components/activity/ActivityCard.stories.jsx @@ -0,0 +1,67 @@ + +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import ActivityCard from './ActivityCard'; +import '../../index.css'; // Global styles +import '../../App.css'; + +export default { + title: 'Components/Activity/ActivityCard', + component: ActivityCard, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + tags: ['autodocs'], +}; + +const Template = (args) => ; + +export const NotStarted = Template.bind({}); +NotStarted.args = { + activity: { + id: 1, + title: 'Future AI Hardware Summit 2026', + start_time: '2026-12-01T09:00:00', + status: '即将开始', + cover_image: 'https://images.unsplash.com/photo-1485827404703-89b55fcc595e?auto=format&fit=crop&q=80', + }, +}; + +export const Ongoing = Template.bind({}); +Ongoing.args = { + activity: { + id: 2, + title: 'Edge Computing Hackathon', + start_time: '2025-10-20T10:00:00', + status: '报名中', + cover_image: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?auto=format&fit=crop&q=80', + }, +}; + +export const Ended = Template.bind({}); +Ended.args = { + activity: { + id: 3, + title: 'Deep Learning Workshop', + start_time: '2023-05-15T14:00:00', + status: '已结束', + cover_image: 'https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&q=80', + }, +}; + +export const SignedUp = Template.bind({}); +SignedUp.args = { + activity: { + id: 4, + title: 'Exclusive Developer Meetup', + start_time: '2025-11-11T18:00:00', + status: '已报名', + cover_image: 'https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&fit=crop&q=80', + }, +}; diff --git a/frontend/src/components/activity/ActivityList.jsx b/frontend/src/components/activity/ActivityList.jsx new file mode 100644 index 0000000..3badb43 --- /dev/null +++ b/frontend/src/components/activity/ActivityList.jsx @@ -0,0 +1,110 @@ + +import React, { useState, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { motion, AnimatePresence } from 'framer-motion'; +import { RightOutlined, LeftOutlined } from '@ant-design/icons'; +import { getActivities } from '../../api'; +import ActivityCard from './ActivityCard'; +import styles from './activity.module.less'; +import { fadeInUp, staggerContainer } from '../../animation'; + +const ActivityList = () => { + const { data: activities, isLoading, error } = useQuery({ + queryKey: ['activities'], + queryFn: async () => { + const res = await getActivities(); + // Handle different response structures + return Array.isArray(res.data) ? res.data : (res.data?.results || []); + }, + staleTime: 5 * 60 * 1000, // 5 minutes cache + }); + + const [currentIndex, setCurrentIndex] = useState(0); + + // Auto-play for desktop carousel + useEffect(() => { + if (!activities || activities.length <= 1) return; + const interval = setInterval(() => { + setCurrentIndex((prev) => (prev + 1) % activities.length); + }, 5000); + return () => clearInterval(interval); + }, [activities]); + + const nextSlide = () => { + if (!activities) return; + setCurrentIndex((prev) => (prev + 1) % activities.length); + }; + + const prevSlide = () => { + if (!activities) return; + setCurrentIndex((prev) => (prev - 1 + activities.length) % activities.length); + }; + + if (isLoading) return
Loading activities...
; + if (error) return null; // Or error state + if (!activities || activities.length === 0) return null; + + return ( + +
+

+ 近期活动 / EVENTS +

+
+ + +
+
+ + {/* Desktop: Carousel (Show one prominent, but allows list structure if needed) + User said: "Activity only shows one, and in the form of a sliding page" + */} +
+ + + + + + +
+ {activities.map((_, idx) => ( + setCurrentIndex(idx)} + /> + ))} +
+
+ + {/* Mobile: Vertical List/Scroll as requested "Mobile vertical scroll" */} +
+ {activities.map((item, index) => ( + + + + ))} +
+
+ ); +}; + +export default ActivityList; diff --git a/frontend/src/components/activity/activity.module.less b/frontend/src/components/activity/activity.module.less new file mode 100644 index 0000000..30902a9 --- /dev/null +++ b/frontend/src/components/activity/activity.module.less @@ -0,0 +1,411 @@ + +@import '../../theme.module.less'; + +.activitySection { + padding: var(--spacing-lg) 0; + width: 100%; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-md); +} + +.sectionTitle { + font-size: 24px; + font-weight: 700; + color: var(--text-primary); + display: flex; + align-items: center; + gap: 8px; + + &::before { + content: ''; + display: block; + width: 4px; + height: 24px; + background: var(--primary-color); + border-radius: 2px; + } +} + +.controls { + display: flex; + gap: var(--spacing-sm); + + @media (max-width: 768px) { + display: none; /* Hide carousel controls on mobile */ + } +} + +.navBtn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: var(--text-primary); + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s; + + &:hover { + background: var(--primary-color); + border-color: var(--primary-color); + } +} + +/* Desktop Carousel */ +.desktopCarousel { + position: relative; + width: 100%; + height: 440px; /* 400px card + space for dots */ + overflow: hidden; + + @media (max-width: 768px) { + display: none; + } +} + +.dots { + display: flex; + justify-content: center; + gap: 8px; + margin-top: var(--spacing-md); +} + +.dot { + width: 8px; + height: 8px; + background: rgba(255, 255, 255, 0.2); + border-radius: 50%; + cursor: pointer; + transition: all 0.3s; + + &.activeDot { + background: var(--primary-color); + transform: scale(1.2); + } +} + +/* Mobile List */ +.mobileList { + display: none; + flex-direction: column; + gap: var(--spacing-md); + + @media (max-width: 768px) { + display: flex; + } +} + +/* --- Card Styles --- */ +.activityCard { + position: relative; + width: 100%; + height: 400px; + border-radius: var(--border-radius-lg); + overflow: hidden; + cursor: pointer; + background: var(--background-card); + box-shadow: var(--box-shadow-base); + transition: all 0.3s ease; + + @media (max-width: 768px) { + height: 300px; + } +} + +.imageContainer { + width: 100%; + height: 100%; + position: relative; + + img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.5s ease; + } +} + +.overlay { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 60%; + background: linear-gradient(to top, rgba(0,0,0,0.9), transparent); + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: var(--spacing-lg); + box-sizing: border-box; +} + +.statusTag { + display: inline-block; + background: var(--primary-color); + color: #fff; + padding: 4px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + margin-bottom: var(--spacing-sm); + width: fit-content; + text-transform: uppercase; +} + +.title { + color: var(--text-primary); + font-size: 24px; + font-weight: 700; + margin-bottom: var(--spacing-xs); + line-height: 1.3; + text-shadow: 0 2px 4px rgba(0,0,0,0.5); + + @media (max-width: 768px) { + font-size: 18px; + } +} + +.activityTitle { + font-size: 28px; + margin-bottom: 16px; + color: #fff; + line-height: 1.3; + + @media (max-width: 768px) { + font-size: 22px; + margin-bottom: 12px; + } +} + +.metaInfo { + display: flex; + gap: 20px; + margin-bottom: 16px; + color: rgba(255, 255, 255, 0.7); + flex-wrap: wrap; + + @media (max-width: 768px) { + gap: 12px; + margin-bottom: 12px; + } +} + +.metaItem { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + + @media (max-width: 768px) { + font-size: 13px; + gap: 6px; + background: rgba(255, 255, 255, 0.05); + padding: 4px 8px; + border-radius: 4px; + } +} + +.statusWrapper { + display: flex; + gap: 10px; +} + +.headerGradient { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 50%; + background: linear-gradient(to top, #1f1f1f, transparent); +} + +.time { + color: var(--text-secondary); + font-size: 14px; + display: flex; + align-items: center; + gap: 6px; +} + +/* Detail Page Styles */ +.detailHeader { + position: relative; + height: 50vh; + min-height: 300px; + width: 100%; + overflow: hidden; + + @media (max-width: 768px) { + height: 40vh; + min-height: 250px; + } +} + +.detailImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +.detailContent { + max-width: 800px; + margin: -60px auto 0; + position: relative; + z-index: 10; + padding: 0 var(--spacing-lg) 100px; /* Bottom padding for fixed footer */ + + @media (max-width: 768px) { + padding: 0 var(--spacing-md) 80px; + margin-top: -40px; + } +} + +.infoCard { + background: var(--background-card); + padding: var(--spacing-lg); + border-radius: var(--border-radius-lg); + box-shadow: var(--box-shadow-base); + margin-bottom: var(--spacing-lg); + border: 1px solid var(--border-color); + + @media (max-width: 768px) { + padding: var(--spacing-md); + margin-bottom: var(--spacing-md); + } +} + +.richText { + color: var(--text-secondary); + line-height: 1.8; + font-size: 16px; + + @media (max-width: 768px) { + font-size: 15px; + } + + img { + max-width: 100%; + border-radius: var(--border-radius-base); + margin: var(--spacing-md) 0; + } + + h1, h2, h3 { + color: var(--text-primary); + margin-top: var(--spacing-lg); + + @media (max-width: 768px) { + margin-top: var(--spacing-md); + font-size: 1.2em; /* slightly smaller headings */ + } + } +} + +.fixedFooter { + position: fixed; + bottom: 0; + left: 0; + width: 100%; + background: rgba(31, 31, 31, 0.95); + backdrop-filter: blur(10px); + padding: var(--spacing-md) var(--spacing-lg); + border-top: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + z-index: 100; + box-shadow: 0 -4px 12px rgba(0,0,0,0.2); +} + +.actionBtn { + background: var(--primary-color); + color: #fff; + border: none; + padding: 12px 32px; + border-radius: 24px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 185, 107, 0.3); + transition: all 0.3s; + + &:disabled { + background: #555; + cursor: not-allowed; + box-shadow: none; + } +} + +/* Markdown Table Styles */ +.richText table { + width: 100%; + border-collapse: collapse; + margin: 16px 0; + background-color: #2d2d2d; + border-radius: 4px; + overflow: hidden; + font-size: 14px; +} + +.richText th, +.richText td { + padding: 12px 16px; + border: 1px solid #434343; + color: #e0e0e0; +} + +.richText th { + background-color: #1f1f1f; + font-weight: 600; + text-align: left; +} + +.richText tr:nth-child(even) { + background-color: #333; +} + +.richText tr:hover { + background-color: #3a3a3a; +} + +/* Code Block Styles */ +.codeBlockWrapper { + position: relative; + margin: 16px 0; + border-radius: 6px; + overflow: hidden; + + &:hover .copyButton { + opacity: 1; + } +} + +.copyButton { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; + padding: 4px 8px; + background-color: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: #fff; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; + opacity: 0; /* Hidden by default, shown on hover */ + display: flex; + align-items: center; + gap: 4px; + + &:hover { + background-color: rgba(255, 255, 255, 0.2); + } +} diff --git a/frontend/src/components/competition/CompetitionCard.jsx b/frontend/src/components/competition/CompetitionCard.jsx new file mode 100644 index 0000000..0c1c202 --- /dev/null +++ b/frontend/src/components/competition/CompetitionCard.jsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { Card, Tag, Typography, Space, Divider } from 'antd'; +import { CalendarOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router-dom'; +import dayjs from 'dayjs'; +import ReactMarkdown from 'react-markdown'; + +const { Title } = Typography; + +const CompetitionCard = ({ competition }) => { + const navigate = useNavigate(); + + const getStatusColor = (status) => { + switch(status) { + case 'published': return 'cyan'; + case 'registration': return 'green'; + case 'submission': return 'blue'; + case 'judging': return 'orange'; + case 'ended': return 'red'; + default: return 'default'; + } + }; + + const getStatusText = (status) => { + switch(status) { + case 'published': return '即将开始'; + case 'registration': return '报名中'; + case 'submission': return '作品提交中'; + case 'judging': return '评审中'; + case 'ended': return '已结束'; + default: return '草稿'; + } + }; + + return ( + + {competition.title} +
+ + {getStatusText(competition.status)} + +
+
+ } + style={{ height: '100%', display: 'flex', flexDirection: 'column', fontSize: 16 }} + bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: 24 }} + onClick={() => navigate(`/competitions/${competition.id}`)} + > + + {competition.title} + + +
+ , + }} + > + {competition.description} + +
+ + + + + + + + {dayjs(competition.start_time).format('YYYY-MM-DD')} ~ {dayjs(competition.end_time).format('YYYY-MM-DD')} + + + + + ); +}; + +export default CompetitionCard; \ No newline at end of file diff --git a/frontend/src/components/competition/CompetitionDetail.jsx b/frontend/src/components/competition/CompetitionDetail.jsx new file mode 100644 index 0000000..49445e6 --- /dev/null +++ b/frontend/src/components/competition/CompetitionDetail.jsx @@ -0,0 +1,445 @@ +import React, { useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Typography, Tabs, Button, Row, Col, Card, Statistic, Tag, Descriptions, Empty, message, Spin, Modal, List, Avatar, Grid } from 'antd'; +import { CalendarOutlined, TrophyOutlined, UserOutlined, FileTextOutlined, CloudUploadOutlined, CopyOutlined, CheckOutlined, MessageOutlined } from '@ant-design/icons'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import dayjs from 'dayjs'; +import { getCompetitionDetail, getProjects, getMyCompetitionEnrollment, enrollCompetition, getComments } from '../../api'; +import ProjectSubmission from './ProjectSubmission'; +import { useAuth } from '../../context/AuthContext'; +import 'github-markdown-css/github-markdown-dark.css'; + +/** + * Get the full URL for an image. + * Handles relative paths and ensures correct API base URL is used. + * @param {string} url - The image URL path + * @returns {string} The full absolute URL + */ +const getImageUrl = (url) => { + if (!url) return ''; + if (url.startsWith('http') || url.startsWith('//')) return url; + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'; + // Remove /api suffix if present to get the root URL for media files + const baseUrl = apiUrl.replace(/\/api\/?$/, ''); + return `${baseUrl}${url}`; +}; + +const { Title, Paragraph } = Typography; +const { useBreakpoint } = Grid; + +/** + * Code block component for markdown rendering with syntax highlighting and copy functionality. + */ +const CodeBlock = ({ inline, className, children, ...props }) => { + const [copied, setCopied] = useState(false); + const match = /language-(\w+)/.exec(className || ''); + const codeString = String(children).replace(/\n$/, ''); + + const handleCopy = () => { + navigator.clipboard.writeText(codeString); + setCopied(true); + message.success('代码已复制'); + setTimeout(() => setCopied(false), 2000); + }; + + return !inline && match ? ( +
+
+ {copied ? : } + {copied ? '已复制' : '复制'} +
+ + {codeString} + +
+ ) : ( + + {children} + + ); +}; + +/** + * Main component for displaying competition details. + * Includes tabs for overview, projects, and leaderboard. + * Responsive design for mobile and desktop. + */ +const CompetitionDetail = () => { + const { id } = useParams(); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const { user, showLoginModal } = useAuth(); + const [activeTab, setActiveTab] = useState('details'); + const [submissionModalVisible, setSubmissionModalVisible] = useState(false); + const [editingProject, setEditingProject] = useState(null); + const [commentsModalVisible, setCommentsModalVisible] = useState(false); + const [currentProjectComments, setCurrentProjectComments] = useState([]); + const [commentsLoading, setCommentsLoading] = useState(false); + + const screens = useBreakpoint(); + const isMobile = !screens.md; + + // Fetch competition details + const { data: competition, isLoading: loadingDetail } = useQuery({ + queryKey: ['competition', id], + queryFn: () => getCompetitionDetail(id).then(res => res.data) + }); + + // Fetch projects (for leaderboard/display) + const { data: projects } = useQuery({ + queryKey: ['projects', id], + queryFn: () => getProjects({ competition: id, status: 'submitted', page_size: 100 }).then(res => res.data) + }); + + // Check enrollment status + const { data: enrollment, refetch: refetchEnrollment } = useQuery({ + queryKey: ['enrollment', id], + queryFn: () => getMyCompetitionEnrollment(id).then(res => res.data), + enabled: !!user, + retry: false + }); + + // Fetch my project if enrolled + const { data: myProjects, isLoading: loadingMyProject } = useQuery({ + queryKey: ['myProject', id, enrollment?.id], + queryFn: () => getProjects({ competition: id, contestant: enrollment.id }).then(res => res.data), + enabled: !!enrollment?.id + }); + + const myProject = myProjects?.results?.[0]; + + /** + * Handle competition enrollment. + * Checks login status and submits enrollment request. + */ + const handleEnroll = async () => { + if (!user) { + showLoginModal(); + return; + } + try { + await enrollCompetition(id, { role: 'contestant' }); + message.success('报名申请已提交,请等待审核'); + refetchEnrollment(); + } catch (error) { + message.error(error.response?.data?.detail || '报名失败'); + } + }; + + /** + * Fetch and display judge comments for a project. + * @param {Object} project - The project object + */ + const handleViewComments = async (project) => { + if (!project) return; + setCommentsLoading(true); + setCommentsModalVisible(true); + try { + const res = await getComments({ project: project.id }); + // Support pagination result or list result + setCurrentProjectComments(res.data?.results || res.data || []); + } catch (error) { + console.error(error); + message.error('获取评语失败'); + } finally { + setCommentsLoading(false); + } + }; + + if (loadingDetail) return ; + if (!competition) return ; + + const isContestant = enrollment?.role === 'contestant' && enrollment?.status === 'approved'; + + const items = [ + { + key: 'details', + label: '比赛详情', + children: ( +
+ + + + {competition.status_display} + + + + {dayjs(competition.start_time).format('YYYY-MM-DD')} + + + {dayjs(competition.end_time).format('YYYY-MM-DD')} + + + + 比赛简介 +
+ , + h1: (props) =>

, + h2: (props) =>

, + h3: (props) =>

, + a: (props) => , + blockquote: (props) =>
, + table: (props) => , + th: (props) =>
, + td: (props) => , + }} + > + {competition.description} + + + + 规则说明 +
+ , + h1: (props) =>

, + h2: (props) =>

, + h3: (props) =>

, + a: (props) => , + blockquote: (props) =>
, + table: (props) => , + th: (props) =>
, + td: (props) => , + }} + > + {competition.rule_description} + + + + 参赛条件 +
+ , + h1: (props) =>

, + h2: (props) =>

, + h3: (props) =>

, + a: (props) => , + blockquote: (props) =>
, + table: (props) => , + th: (props) => + } + actions={[ + , + + ]} + > + } + /> + + + ))} + {(!projects?.results || projects.results.length === 0) && ( + + )} + + ) + }, + { + key: 'leaderboard', + label: '排行榜', + children: ( + + {/* Leaderboard Logic: sort by final_score descending */} + {[...(projects?.results || [])].sort((a, b) => b.final_score - a.final_score).map((project, index) => ( +
+
+ #{index + 1} +
+
+
{project.title}
+
{project.contestant_info?.nickname}
+
+
+ {project.final_score || 0} +
+
+ ))} +
+ ) + } + ]; + + return ( +
+
+
+ {competition.title} +
+ {enrollment ? ( + + ) : ( + + )} + {isContestant && ( + <> + + {myProject && ( + + )} + + )} +
+
+
+ + + + {submissionModalVisible && ( + { + setSubmissionModalVisible(false); + setEditingProject(null); + }} + onSuccess={() => { + setSubmissionModalVisible(false); + setEditingProject(null); + // Refetch projects + queryClient.invalidateQueries(['projects']); + queryClient.invalidateQueries(['myProject']); + }} + /> + )} + + setCommentsModalVisible(false)} + footer={null} + > + ( + + } />} + title={item.judge_name || '评委'} + description={ +
+
{item.content}
+
+ {dayjs(item.created_at).format('YYYY-MM-DD HH:mm')} +
+
+ } + /> +
+ )} + locale={{ emptyText: '暂无评语' }} + /> +
+
+ ); +}; + +export default CompetitionDetail; \ No newline at end of file diff --git a/frontend/src/components/competition/CompetitionList.jsx b/frontend/src/components/competition/CompetitionList.jsx new file mode 100644 index 0000000..0bcc1e9 --- /dev/null +++ b/frontend/src/components/competition/CompetitionList.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Row, Col, Typography, Input, Select, Empty, Spin } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import { getCompetitions } from '../../api'; +import CompetitionCard from './CompetitionCard'; +import { useState } from 'react'; + +const { Title } = Typography; +const { Search } = Input; +const { Option } = Select; + +const CompetitionList = () => { + const [params, setParams] = useState({ page: 1, page_size: 10 }); + const [search, setSearch] = useState(''); + const [status, setStatus] = useState('all'); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['competitions', params, search, status], + queryFn: () => getCompetitions({ + ...params, + search: search || undefined, + status: status !== 'all' ? status : undefined + }), + keepPreviousData: true + }); + + const handleSearch = (value) => { + setSearch(value); + setParams({ ...params, page: 1 }); + }; + + const handleStatusChange = (value) => { + setStatus(value); + setParams({ ...params, page: 1 }); + }; + + if (isError) return ; + + return ( +
+
+ 赛事中心 +
+ + +
+
+ + {isLoading ? ( +
+ +
+ ) : ( + <> + {data?.data?.results?.length > 0 ? ( + + {data.data.results.map((comp) => ( +
+ + + ))} + + ) : ( + + )} + + )} + + ); +}; + +export default CompetitionList; \ No newline at end of file diff --git a/frontend/src/components/competition/ProjectDetail.jsx b/frontend/src/components/competition/ProjectDetail.jsx new file mode 100644 index 0000000..3eaf105 --- /dev/null +++ b/frontend/src/components/competition/ProjectDetail.jsx @@ -0,0 +1,197 @@ +import React from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { Typography, Card, Button, Row, Col, Tag, Descriptions, Empty, Spin, Avatar, List, Image, Grid } from 'antd'; +import { UserOutlined, ArrowLeftOutlined, LinkOutlined, FileTextOutlined, TrophyOutlined, MessageOutlined } from '@ant-design/icons'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import dayjs from 'dayjs'; +import { getProjectDetail, getComments } from '../../api'; +import 'github-markdown-css/github-markdown-dark.css'; + +const { Title, Paragraph, Text } = Typography; +const { useBreakpoint } = Grid; + +const getImageUrl = (url) => { + if (!url) return ''; + if (url.startsWith('http') || url.startsWith('//')) return url; + const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'; + const baseUrl = apiUrl.replace(/\/api\/?$/, ''); + return `${baseUrl}${url}`; +}; + +const CodeBlock = ({ inline, className, children, ...props }) => { + const match = /language-(\w+)/.exec(className || ''); + return !inline && match ? ( + + {String(children).replace(/\n$/, '')} + + ) : ( + + {children} + + ); +}; + +const ProjectDetail = () => { + const { id } = useParams(); + const navigate = useNavigate(); + const screens = useBreakpoint(); + const isMobile = !screens.md; + + const { data: project, isLoading } = useQuery({ + queryKey: ['project', id], + queryFn: () => getProjectDetail(id).then(res => res.data) + }); + + const { data: comments } = useQuery({ + queryKey: ['comments', id], + queryFn: () => getComments({ project: id }).then(res => res.data?.results || res.data || []), + enabled: !!project + }); + + if (isLoading) return ; + if (!project) return ; + + return ( +
+ + + + +
+ + + + + + } size="small" style={{ marginRight: 8 }} /> + {project.contestant_info?.nickname || '匿名用户'} + + + {dayjs(project.created_at).format('YYYY-MM-DD HH:mm')} + + + + {project.final_score || 0} + + + + + {project.status === 'submitted' ? '已提交' : '草稿'} + + + + + + {project.link && ( + + )} + {project.file && ( + + )} + + + + + {project.title} + {project.subtitle} + +
+ 项目详情 +
+ , + h1: (props) =>

, + h2: (props) =>

, + h3: (props) =>

, + a: (props) => , + }} + > + {project.description || '暂无描述'} + +

+
+ + {comments && comments.length > 0 && ( +
+ 评委评语 + ( + + } style={{ backgroundColor: '#1890ff' }} />} + title={ +
+ {item.judge_name || '评委'} + {item.score && ( + + {item.score}分 + + )} +
+ } + description={ +
+
{item.content}
+
+ {dayjs(item.created_at).format('YYYY-MM-DD HH:mm')} +
+
+ } + /> +
+ )} + /> +
+ )} + + + + + + ); +}; + +export default ProjectDetail; diff --git a/frontend/src/components/competition/ProjectSubmission.jsx b/frontend/src/components/competition/ProjectSubmission.jsx new file mode 100644 index 0000000..f641ea7 --- /dev/null +++ b/frontend/src/components/competition/ProjectSubmission.jsx @@ -0,0 +1,191 @@ +import React, { useState } from 'react'; +import { Card, Button, Form, Input, Upload, App, Modal, Select } from 'antd'; +import { UploadOutlined, CloudUploadOutlined, LinkOutlined } from '@ant-design/icons'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createProject, updateProject, submitProject, uploadProjectFile } from '../../api'; + +const { TextArea } = Input; +const { Option } = Select; + +const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }) => { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [fileList, setFileList] = useState([]); + const queryClient = useQueryClient(); + + // Reset form when initialValues changes (important for switching between create/edit) + React.useEffect(() => { + if (initialValues) { + form.setFieldsValue(initialValues); + } else { + form.resetFields(); + } + }, [initialValues, form]); + + const createMutation = useMutation({ + mutationFn: createProject, + onSuccess: () => { + message.success('项目创建成功'); + queryClient.invalidateQueries(['projects']); + onSuccess(); + }, + onError: (error) => { + message.error(`创建失败: ${error.message}`); + } + }); + + const updateMutation = useMutation({ + mutationFn: (data) => updateProject(initialValues.id, data), + onSuccess: () => { + message.success('项目更新成功'); + queryClient.invalidateQueries(['projects']); + onSuccess(); + }, + onError: (error) => { + message.error(`更新失败: ${error.message}`); + } + }); + + const uploadMutation = useMutation({ + mutationFn: uploadProjectFile, + onSuccess: (data) => { + message.success('文件上传成功'); + setFileList([...fileList, data]); // Add file to list (assuming response format) + }, + onError: (error) => { + message.error(`上传失败: ${error.message}`); + } + }); + + const onFinish = (values) => { + const data = { + ...values, + competition: competitionId, + // Handle file URLs/IDs if needed in create/update + }; + + if (initialValues?.id) { + updateMutation.mutate(data); + } else { + createMutation.mutate(data); + } + }; + + const handleUpload = ({ file, onSuccess, onError }) => { + if (!initialValues?.id) { + message.warning('请先保存项目基本信息再上传文件'); + // Prevent default upload + onError(new Error('请先保存项目')); + return; + } + + const formData = new FormData(); + formData.append('file', file); + formData.append('project', initialValues?.id || ''); // Need project ID first usually + + uploadMutation.mutate(formData, { + onSuccess: (data) => { + onSuccess(data); + }, + onError: (error) => { + onError(error); + } + }); + }; + + return ( + +
+ + + + + +
, + td: (props) => , + }} + > + {competition.condition_description} + + + + ) + }, + { + key: 'projects', + label: '参赛项目', + children: ( + + {projects?.results?.map(project => ( +