From 3fe6bf6dbc5f8bf47115f3ee522be0436e95e3cf Mon Sep 17 00:00:00 2001 From: Pine Date: Thu, 3 Sep 2026 12:40:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=99=BB=E5=BD=95=E6=B3=A8=E5=85=A5=20?= =?UTF-8?q?MQTT=20=E5=87=AD=E8=AF=81=E3=80=81=E5=AE=98=E6=96=B9=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E4=BD=93=E5=B9=B3=E5=8F=B0=E5=8C=96=E3=80=81=E4=BC=9A?= =?UTF-8?q?=E8=AF=9DID=E6=89=A9=E5=AE=B9=E3=80=81=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E5=88=86=E8=B4=A6/=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 43 ++ .../versions/0045_wx_applyment_split_full.py | 52 ++ .../0046_credit_certification_full.py | 167 +++++ .../versions/0047_training_certification.py | 155 +++++ alembic/versions/0048_official_agents.py | 57 ++ .../0049_official_agents_scope_docs.py | 35 + app/api/routers/auth.py | 30 +- app/api/routers/bootstrap.py | 88 ++- app/api/routers/rbac_agents.py | 197 ++++++ app/api/routers/rbac_public.py | 26 + app/api/routers/rbac_training.py | 624 ++++++++++++++++++ app/api/routers/templates.py | 37 +- app/api/schemas/auth.py | 2 + app/config.py | 11 + app/im/__init__.py | 2 + app/im/client.py | 110 +++ app/im/router.py | 143 ++++ app/infrastructure/hall_repositories.py | 4 + app/infrastructure/models.py | 231 +++++++ app/infrastructure/seed.py | 30 +- app/pay/config.py | 37 +- app/pay/models.py | 8 + app/pay/profitsharing.py | 222 ++++++- app/pay/repository.py | 17 + app/pay/routers.py | 44 ++ app/pay/service.py | 127 +++- app/pay/wxpay.py | 8 +- app/services/settlement_service.py | 184 +++++- app/services/task_service.py | 22 +- dispatcher.py | 4 + serverrun/mqtt/docker-compose.yml | 60 +- wechatpayv3/async_/media.py | 4 +- 32 files changed, 2637 insertions(+), 144 deletions(-) create mode 100644 alembic/versions/0045_wx_applyment_split_full.py create mode 100644 alembic/versions/0046_credit_certification_full.py create mode 100644 alembic/versions/0047_training_certification.py create mode 100644 alembic/versions/0048_official_agents.py create mode 100644 alembic/versions/0049_official_agents_scope_docs.py create mode 100644 app/api/routers/rbac_agents.py create mode 100644 app/api/routers/rbac_public.py create mode 100644 app/api/routers/rbac_training.py create mode 100644 app/im/__init__.py create mode 100644 app/im/client.py create mode 100644 app/im/router.py diff --git a/.env.example b/.env.example index b6fd97d..ce2b923 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,49 @@ PINEAGENTS_RECHARGE_MIN_YUAN=1 PINEAGENTS_RECHARGE_MAX_YUAN=5000 PINEAGENTS_RECHARGE_EXPIRE_MINUTES=15 +# ── 微信支付【服务商】分账 / 资金托管配置(OPC 收款认证)───────────────── +# ⚠️ 与上方直连商户配置并存:收单走直连商户号,分账/进件用服务商商户号; +# 分账功能以本段为开关(SP_MCHID + 服务商证书/APIv3 + 回调地址齐备才启用 profitsharing_enabled())。 +# ⚠️ 开通前提:平台主体需有营业执照,在微信支付商户平台完成「服务商入驻」+ +# 「服务商分账」产品开通 + 绑定服务商 AppID + 配置分账回调(无需 ICP/EDI)。 +# ⚠️ 关键:服务商商户号通常 ≠ 直连商户号,是两个独立商户号,**必须为服务商单独配置 +# API 证书/序列号/APIv3 密钥**(下方 SP_* 证书项),否则分账/进件签名失败(SIGN_ERROR)。 +# +# PINEAGENTS_WX_SP_MCHID —— 服务商商户号(10 位数字,sp_mchid,发起分账/进件的请求主体) +# 在哪里获取:微信支付商户平台 pay.weixin.qq.com → 登录服务商账户 → 「商户信息」→ 服务商商户号 +PINEAGENTS_WX_SP_MCHID= +# PINEAGENTS_WX_SP_APPID —— 服务商 AppID(PERSONAL_OPENID 个人分账接收方的 openid 归属该 AppID; +# 分账接口的 appid 参数也用它) +# 在哪里获取:微信公众平台 mp.weixin.qq.com → 小程序/公众号 → 「开发」→「开发管理」→ AppID; +# 注意:该 AppID 需在服务商后台「AppID 授权」中完成与服务商商户号的绑定 +PINEAGENTS_WX_SP_APPID= +# PINEAGENTS_WX_SP_CERT_SERIAL_NO —— 服务商 API 证书序列号(APIv3 请求签名头 Wechatpay-Serial) +# 在哪里获取:微信支付服务商商户平台 pay.weixin.qq.com/partner → 「账户中心>API安全>API证书」 +# → 查看证书序列号(形如 15DF0640...) +PINEAGENTS_WX_SP_CERT_SERIAL_NO= +# PINEAGENTS_WX_SP_PRIVATE_KEY_PATH —— 服务商 API 私钥 apiclient_key.pem 落盘路径 +# 在哪里获取:服务商商户平台「API安全>API证书」→ 下载证书,解压得 apiclient_key.pem, +# 上传到服务器(如 serverdata/keys/wechat_certs/sp_certs/apiclient_key.pem) +PINEAGENTS_WX_SP_PRIVATE_KEY_PATH=serverdata/keys/wechat_certs/sp_certs/apiclient_key.pem +# PINEAGENTS_WX_SP_APIV3_KEY —— 服务商 APIv3 密钥(32 位;回调报文解密用,勿与直连商户相同) +# 在哪里获取:服务商商户平台「账户中心>API安全>APIv3密钥」→ 设置/重置后保存 +PINEAGENTS_WX_SP_APIV3_KEY= +# PINEAGENTS_WX_SP_CERT_DIR —— 服务商平台证书/公钥自动更新落盘目录(运行时数据) +# 在哪里获取:自行指定目录(如 serverdata/keys/wechat_certs/sp_certs),首次请求自动下载 +PINEAGENTS_WX_SP_CERT_DIR=serverdata/keys/wechat_certs/sp_certs +# PINEAGENTS_WX_SP_PUBLIC_KEY_PATH / _ID —— 服务商公钥模式(可选,二选一) +# 新服务商商户启用「微信支付公钥」模式时配置 pub_key.pem 与 PUB_KEY_ID(在 API安全>公钥 获取); +# 未配置则走平台证书自动更新(推荐,无需人工维护) +PINEAGENTS_WX_SP_PUBLIC_KEY_PATH= +PINEAGENTS_WX_SP_PUBLIC_KEY_ID= +# PINEAGENTS_WX_SPLIT_NOTIFY_URL —— 分账结果回调通知地址(必须是微信可达的公网 HTTPS) +# 在哪里获取:平台自己的回调 URL(本项目为 https://opc.pinesound.cn/opc/pay/profitsharing/notify), +# 并到微信商户平台 → 服务商分账 → 「回调设置」配置相同的地址,接收分账结果通知 +PINEAGENTS_WX_SPLIT_NOTIFY_URL=https://opc.pinesound.cn/opc/pay/profitsharing/notify +# PINEAGENTS_WX_SPLIT_COMMISSION_RATE —— 平台佣金比例(分账给服务商商户号的部分,默认 0.05=5%) +# 在哪里获取:平台自身运营策略,需与 settlement_service 的 COMMISSION_RATE 保持一致 +PINEAGENTS_WX_SPLIT_COMMISSION_RATE=0.05 + # ---- 数据库(SQLite→MySQL 切换;未配置默认 serverdata/data/app.db)---- # 示例:mysql+asyncmy://user:pass@192.168.1.3:8091/opc?charset=utf8mb4 # 首次迁移:uv run python scripts/migrate_sqlite_to_mysql.py(建表+搬数据+行数校验) diff --git a/alembic/versions/0045_wx_applyment_split_full.py b/alembic/versions/0045_wx_applyment_split_full.py new file mode 100644 index 0000000..7c0700c --- /dev/null +++ b/alembic/versions/0045_wx_applyment_split_full.py @@ -0,0 +1,52 @@ +"""服务商子商户进件生命周期 + 分账生命周期完整字段。 + +- ``payment_bindings`` 增列:applyment_state / sign_url / account_validation_json / + audit_detail_json / split_allowed / split_max_ratio(进件状态跟踪 + 超管签约引导 + 分账授权) +- ``escrows`` 增列:payer_sub_mchid(出资特约商户号)/ split_detail(分账生命周期快照) + +Revision ID: 0045_wx_applyment_split_full +Revises: 0044_incubator_area_status +Create Date: 2026-09-02 +""" +from __future__ import annotations + +from alembic import op + +revision = "0045_wx_applyment_split_full" +down_revision = "0044_incubator_area_status" +branch_labels = None +depends_on = None + + +def _add_cols(table: str, cols: list[tuple[str, str]]) -> None: + bind = op.get_bind() + existing = {c["name"] for c in bind.dialect.get_columns(bind, table)} + for name, ddl in cols: + if name not in existing: + op.execute(f"ALTER TABLE {table} ADD COLUMN {name} {ddl}") + + +def upgrade() -> None: + _add_cols("payment_bindings", [ + ("applyment_state", "VARCHAR(32) NOT NULL DEFAULT ''"), + ("sign_url", "VARCHAR(512) NOT NULL DEFAULT ''"), + ("account_validation_json", "TEXT"), + ("audit_detail_json", "TEXT"), + ("split_allowed", "VARCHAR(32) NOT NULL DEFAULT ''"), + ("split_max_ratio", "INTEGER NOT NULL DEFAULT 0"), + ]) + _add_cols("escrows", [ + ("payer_sub_mchid", "VARCHAR(64) NOT NULL DEFAULT ''"), + ("split_detail", "TEXT"), + ]) + + +def downgrade() -> None: + # SQLite 不支持 DROP COLUMN,仅 MySQL 执行;幂等降级 + bind = op.get_bind() + if bind.dialect.name == "mysql": + for name in ("split_max_ratio", "split_allowed", "audit_detail_json", + "account_validation_json", "sign_url", "applyment_state"): + op.execute(f"ALTER TABLE payment_bindings DROP COLUMN {name}") + for name in ("split_detail", "payer_sub_mchid"): + op.execute(f"ALTER TABLE escrows DROP COLUMN {name}") diff --git a/alembic/versions/0046_credit_certification_full.py b/alembic/versions/0046_credit_certification_full.py new file mode 100644 index 0000000..28482d5 --- /dev/null +++ b/alembic/versions/0046_credit_certification_full.py @@ -0,0 +1,167 @@ +"""信用认证体系完整表:认证类型配置 + 认证申请 + 技能测试。 + +- ``certification_types``:认证类型配置(运营可配置,个人/企业/OPC人才/技能四大类) +- ``certifications``:认证申请表(统一所有认证类型,替代旧 opc_certifications 的单一用途) +- ``skill_tests``:技能测试记录表(标准化测试 + 防作弊 + 评分) + +Revision ID: 0046_credit_certification_full +Revises: 0045_wx_applyment_split_full +Create Date: 2026-09-02 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "0046_credit_certification_full" +down_revision = "0045_wx_applyment_split_full" +branch_labels = None +depends_on = None + + +def _table_exists(name: str) -> bool: + bind = op.get_bind() + return name in bind.dialect.get_table_names(bind) + + +def upgrade() -> None: + # 认证类型配置表 + if not _table_exists("certification_types"): + op.create_table( + "certification_types", + sa.Column("id", sa.String(64), primary_key=True), + sa.Column("code", sa.String(64), unique=True, nullable=False, default=""), + sa.Column("category", sa.String(32), nullable=False, default="personal"), + sa.Column("name", sa.String(128), nullable=False, default=""), + sa.Column("description", sa.Text, default=""), + sa.Column("fee", sa.Integer, nullable=False, default=0), + sa.Column("validity_days", sa.Integer, nullable=False, default=0), + sa.Column("credit_points", sa.Integer, nullable=False, default=0), + sa.Column("levels_json", sa.Text, default="[]"), + sa.Column("materials_schema", sa.Text, default="{}"), + sa.Column("review_mode", sa.String(16), nullable=False, default="manual"), + sa.Column("api_provider", sa.String(64), default=""), + sa.Column("badge_icon", sa.String(64), default=""), + sa.Column("badge_color", sa.String(16), default=""), + sa.Column("sort_order", sa.Integer, nullable=False, default=0), + sa.Column("is_active", sa.Boolean, nullable=False, default=True), + sa.Column("created_at", sa.String(32), default=""), + sa.Column("updated_at", sa.String(32), default=""), + ) + op.create_index("ix_certification_types_code", "certification_types", ["code"]) + + # 认证申请表 + if not _table_exists("certifications"): + op.create_table( + "certifications", + sa.Column("id", sa.String(64), primary_key=True), + sa.Column("user_id", sa.String(64), nullable=False, default="", index=True), + sa.Column("enterprise_id", sa.String(64), nullable=False, default="", index=True), + sa.Column("cert_type", sa.String(64), nullable=False, default="", index=True), + sa.Column("cert_category", sa.String(32), nullable=False, default="personal"), + sa.Column("level", sa.String(32), default=""), + sa.Column("status", sa.String(32), nullable=False, default="not_started", index=True), + sa.Column("materials_json", sa.Text, default="{}"), + sa.Column("score", sa.Integer, nullable=False, default=0), + sa.Column("credit_points", sa.Integer, nullable=False, default=0), + sa.Column("fee_amount", sa.Integer, nullable=False, default=0), + sa.Column("payment_id", sa.String(64), default=""), + sa.Column("submitted_at", sa.String(32), default=""), + sa.Column("approved_at", sa.String(32), default=""), + sa.Column("expires_at", sa.String(32), default=""), + sa.Column("reviewer_id", sa.String(64), default=""), + sa.Column("review_note", sa.Text, default=""), + sa.Column("revoke_reason", sa.Text, default=""), + sa.Column("revoked_at", sa.String(32), default=""), + sa.Column("created_at", sa.String(32), default=""), + sa.Column("updated_at", sa.String(32), default=""), + ) + op.create_index("ix_certifications_user_id", "certifications", ["user_id"]) + op.create_index("ix_certifications_cert_type", "certifications", ["cert_type"]) + op.create_index("ix_certifications_status", "certifications", ["status"]) + + # 技能测试记录表 + if not _table_exists("skill_tests"): + op.create_table( + "skill_tests", + sa.Column("id", sa.String(64), primary_key=True), + sa.Column("user_id", sa.String(64), nullable=False, default="", index=True), + sa.Column("skill_domain", sa.String(64), nullable=False, default="", index=True), + sa.Column("status", sa.String(32), nullable=False, default="paid"), + sa.Column("score", sa.Integer, nullable=False, default=0), + sa.Column("level", sa.String(32), default=""), + sa.Column("objective_score", sa.Integer, nullable=False, default=0), + sa.Column("subjective_score", sa.Integer, nullable=False, default=0), + sa.Column("answers_json", sa.Text, default="{}"), + sa.Column("cheating_flags", sa.Integer, nullable=False, default=0), + sa.Column("started_at", sa.String(32), default=""), + sa.Column("submitted_at", sa.String(32), default=""), + sa.Column("scored_at", sa.String(32), default=""), + sa.Column("certification_id", sa.String(64), default="", index=True), + sa.Column("created_at", sa.String(32), default=""), + sa.Column("updated_at", sa.String(32), default=""), + ) + + # 灌种子数据:基础认证类型 + bind = op.get_bind() + existing = {r[0] for r in bind.execute(sa.text("SELECT code FROM certification_types")).fetchall()} + + seed_types = [ + # 个人认证(免费) + ("id_verify", "personal", "实名认证", "身份证二要素/三要素核验,确认真实身份", 0, 0, 30, "manual", "ShieldFilled", "#52C41A", 10), + ("education", "personal", "学历认证", "学信网核验或毕业证人工审核", 0, 0, 20, "hybrid", "GraduationCapFilled", "#9BBBF4", 20), + ("professional_cert", "personal", "职业资格认证", "人社部或权威机构职业资格证书", 0, 1825, 20, "hybrid", "CertificateFilled", "#E4D48F", 30), + ("bank_card", "personal", "银行卡认证", "银行四要素核验,用于收款", 0, 0, 10, "auto", "CreditCardFilled", "#8BC8EA", 40), + ("face_verify", "personal", "人脸识别", "活体检测 + 人脸比对", 0, 0, 10, "auto", "CircleUserFilled", "#DEBEF8", 50), + # 企业认证(免费) + ("business_reg", "enterprise", "工商注册认证", "企业工商信息 API 核验", 0, 0, 20, "auto", "BuildingFilled", "#94D4D0", 10), + ("legal_person_verify", "enterprise", "法人实名认证", "法定代表人身份核验", 0, 0, 10, "auto", "ShieldFilled", "#52C41A", 20), + ("corporate_account", "enterprise", "对公账户认证", "银行小额打款核验", 0, 0, 15, "hybrid", "LandmarkFilled", "#8BC8EA", 30), + ("enterprise_qualification", "enterprise", "企业资质认证", "建筑/IT/设计等企业资质证书", 0, 365, 20, "manual", "AwardFilled", "#E4D48F", 40), + ("enterprise_credit", "enterprise", "企业信用认证", "第三方企业信用报告", 0, 365, 20, "auto", "TrendingUpFilled", "#A2DDAA", 50), + # OPC 人才认证(付费) + ("opc_talent", "opc_talent", "OPC 人才认证", "平台官方高级认证,7项条件校验+双人复核,专属徽章与权益", 29900, 365, 50, "manual", "CrownFilled", "#E4D48F", 10), + # 技能认证(按需付费,领域配置在 levels_json 中) + ("skill_ui_design", "skill", "UI 设计技能认证", "UI 设计标准化测试,初/中/高/专家四级", 4900, 730, 20, "auto", "PaletteFilled", "#9BBBF4", 10), + ("skill_graphic_design", "skill", "平面设计技能认证", "平面设计标准化测试", 4900, 730, 20, "auto", "ImageFilled", "#EAA7B2", 20), + ("skill_frontend_dev", "skill", "前端开发技能认证", "前端开发标准化测试(HTML/CSS/JS/框架)", 5900, 730, 20, "auto", "CodeFilled", "#94D4D0", 30), + ("skill_backend_dev", "skill", "后端开发技能认证", "后端开发标准化测试(语言/框架/数据库)", 5900, 730, 20, "auto", "ServerFilled", "#A2DDAA", 40), + ("skill_copywriting", "skill", "文案写作技能认证", "文案写作标准化测试", 3900, 730, 20, "auto", "PenFilled", "#E1B98F", 50), + ("skill_translation", "skill", "翻译技能认证", "中英翻译标准化测试", 3900, 730, 20, "auto", "LanguagesFilled", "#C9A7E8", 60), + ("skill_data_analysis", "skill", "数据分析技能认证", "数据分析标准化测试(SQL/Excel/统计)", 4900, 730, 20, "auto", "ChartBarFilled", "#8BC8EA", 70), + ] + + for code, category, name, desc, fee, validity, credit, review_mode, badge_icon, badge_color, sort_order in seed_types: + if code not in existing: + op.execute( + sa.text( + "INSERT INTO certification_types (id, code, category, name, description, fee, validity_days, " + "credit_points, levels_json, materials_schema, review_mode, api_provider, badge_icon, " + "badge_color, sort_order, is_active, created_at, updated_at) " + "VALUES (:id, :code, :category, :name, :description, :fee, :validity, :credit, '[]', '{}', " + ":review_mode, '', :badge_icon, :badge_color, :sort_order, 1, '', '')" + ), + { + "id": f"certtype_{code}", + "code": code, + "category": category, + "name": name, + "description": desc, + "fee": fee, + "validity": validity, + "credit": credit, + "review_mode": review_mode, + "badge_icon": badge_icon, + "badge_color": badge_color, + "sort_order": sort_order, + }, + ) + + +def downgrade() -> None: + if _table_exists("skill_tests"): + op.drop_table("skill_tests") + if _table_exists("certifications"): + op.drop_table("certifications") + if _table_exists("certification_types"): + op.drop_table("certification_types") diff --git a/alembic/versions/0047_training_certification.py b/alembic/versions/0047_training_certification.py new file mode 100644 index 0000000..93fbe2c --- /dev/null +++ b/alembic/versions/0047_training_certification.py @@ -0,0 +1,155 @@ +"""培训与技能认证体系:课程/章节/课时/报名/进度/证书 + +Revision ID: 0047 +Revises: 0046 +Create Date: 2026-09-02 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0047" +down_revision = "0046_credit_certification_full" +branch_labels = None +depends_on = None + + +def upgrade(): + # courses + op.create_table( + "training_courses", + sa.Column("id", sa.String(32), primary_key=True), + sa.Column("title", sa.String(200), default=""), + sa.Column("subtitle", sa.String(500), default=""), + sa.Column("description", sa.Text, default=""), + sa.Column("cover_url", sa.String(500), default=""), + sa.Column("category", sa.String(50), default="", index=True), + sa.Column("level", sa.String(20), default="beginner"), + sa.Column("price", sa.Integer, default=0), + sa.Column("duration_minutes", sa.Integer, default=0), + sa.Column("lesson_count", sa.Integer, default=0), + sa.Column("chapter_count", sa.Integer, default=0), + sa.Column("instructor_id", sa.String(32), default=""), + sa.Column("instructor_name", sa.String(100), default=""), + sa.Column("instructor_title", sa.String(100), default=""), + sa.Column("instructor_avatar", sa.String(500), default=""), + sa.Column("tags_json", sa.Text, default="[]"), + sa.Column("prerequisites", sa.Text, default=""), + sa.Column("learning_outcomes", sa.Text, default=""), + sa.Column("status", sa.String(20), default="draft"), + sa.Column("is_featured", sa.Integer, default=0), + sa.Column("sort_order", sa.Integer, default=0), + sa.Column("enroll_count", sa.Integer, default=0), + sa.Column("rating", sa.Float, default=0.0), + sa.Column("rating_count", sa.Integer, default=0), + sa.Column("created_at", sa.String(32), default=""), + sa.Column("updated_at", sa.String(32), default=""), + ) + + # course_chapters + op.create_table( + "training_chapters", + sa.Column("id", sa.String(32), primary_key=True), + sa.Column("course_id", sa.String(32), default="", index=True), + sa.Column("title", sa.String(200), default=""), + sa.Column("description", sa.String(500), default=""), + sa.Column("sort_order", sa.Integer, default=0), + sa.Column("created_at", sa.String(32), default=""), + ) + + # course_lessons + op.create_table( + "training_lessons", + sa.Column("id", sa.String(32), primary_key=True), + sa.Column("chapter_id", sa.String(32), default="", index=True), + sa.Column("course_id", sa.String(32), default="", index=True), + sa.Column("title", sa.String(200), default=""), + sa.Column("type", sa.String(20), default="video"), + sa.Column("content", sa.Text, default=""), + sa.Column("duration_minutes", sa.Integer, default=0), + sa.Column("sort_order", sa.Integer, default=0), + sa.Column("is_free", sa.Integer, default=0), + sa.Column("is_exam", sa.Integer, default=0), + sa.Column("created_at", sa.String(32), default=""), + ) + + # course_enrollments + op.create_table( + "training_course_enrollments", + sa.Column("id", sa.String(32), primary_key=True), + sa.Column("user_id", sa.String(32), default="", index=True), + sa.Column("course_id", sa.String(32), default="", index=True), + sa.Column("status", sa.String(20), default="enrolled"), + sa.Column("progress_percent", sa.Integer, default=0), + sa.Column("completed_lessons", sa.Integer, default=0), + sa.Column("total_lessons", sa.Integer, default=0), + sa.Column("last_lesson_id", sa.String(32), default=""), + sa.Column("last_studied_at", sa.String(32), default=""), + sa.Column("total_watch_seconds", sa.Integer, default=0), + sa.Column("enrolled_at", sa.String(32), default=""), + sa.Column("completed_at", sa.String(32), default=""), + sa.Column("price_paid", sa.Integer, default=0), + sa.Column("created_at", sa.String(32), default=""), + ) + + # course_lesson_progress + op.create_table( + "training_lesson_progress", + sa.Column("id", sa.String(32), primary_key=True), + sa.Column("enrollment_id", sa.String(32), default="", index=True), + sa.Column("user_id", sa.String(32), default="", index=True), + sa.Column("lesson_id", sa.String(32), default="", index=True), + sa.Column("course_id", sa.String(32), default="", index=True), + sa.Column("status", sa.String(20), default="not_started"), + sa.Column("watch_seconds", sa.Integer, default=0), + sa.Column("quiz_score", sa.Integer, default=0), + sa.Column("started_at", sa.String(32), default=""), + sa.Column("completed_at", sa.String(32), default=""), + sa.Column("updated_at", sa.String(32), default=""), + ) + + # course_certificates + op.create_table( + "training_certificates", + sa.Column("id", sa.String(32), primary_key=True), + sa.Column("certificate_no", sa.String(50), default="", index=True), + sa.Column("user_id", sa.String(32), default="", index=True), + sa.Column("course_id", sa.String(32), default="", index=True), + sa.Column("course_title", sa.String(200), default=""), + sa.Column("instructor_name", sa.String(100), default=""), + sa.Column("duration_minutes", sa.Integer, default=0), + sa.Column("final_score", sa.Integer, default=0), + sa.Column("issued_at", sa.String(32), default=""), + sa.Column("badge_id", sa.String(32), default=""), + sa.Column("is_verified", sa.Integer, default=1), + sa.Column("created_at", sa.String(32), default=""), + ) + + # 种子数据:8个技能领域的示例课程(draft状态,运营可编辑发布) + from datetime import datetime + now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + seed_courses = [ + ("UI设计入门到精通", "ui_design", "beginner", 0, 120, 12, 3, "张设计", "资深UI设计师", "从基础设计原则到完整项目实战,涵盖Figma/Sketch工具使用"), + ("平面设计实战课程", "graphic_design", "beginner", 0, 90, 10, 2, "李创意", "品牌设计专家", "海报/LOGO/品牌VI设计全流程教学"), + ("前端开发工程师培养", "frontend_dev", "intermediate", 19900, 180, 20, 4, "王前端", "全栈开发工程师", "HTML/CSS/JavaScript/React/Vue全栈前端开发"), + ("后端开发实战", "backend_dev", "intermediate", 19900, 200, 22, 5, "赵后端", "后端架构师", "Python/Node.js/数据库/API设计后端开发"), + ("文案写作训练营", "copywriting", "beginner", 0, 60, 8, 2, "陈文案", "资深文案策划", "从标题到结尾,写出高转化率的营销文案"), + ("翻译技能提升", "translation", "intermediate", 9900, 90, 10, 3, "刘翻译", "专业翻译师", "中英互译技巧、行业术语、CAT工具使用"), + ("数据分析入门", "data_analysis", "beginner", 0, 100, 12, 3, "孙数据", "数据分析师", "Excel/SQL/Python数据分析全流程"), + ("项目管理实战", "project_management", "intermediate", 14900, 120, 14, 3, "周项目", "PMP认证项目经理", "敏捷/瀑布项目管理方法与工具实战"), + ] + for i, (title, cat, level, price, dur, lessons, chapters, instructor, inst_title, desc) in enumerate(seed_courses): + op.execute( + f"INSERT INTO training_courses (id, title, description, category, level, price, duration_minutes, " + f"lesson_count, chapter_count, instructor_name, instructor_title, status, sort_order, created_at, updated_at) " + f"VALUES ('course_seed_{i+1:03d}', '{title}', '{desc}', '{cat}', '{level}', {price}, {dur}, " + f"{lessons}, {chapters}, '{instructor}', '{inst_title}', 'draft', {i+1}, '{now}', '{now}')" + ) + + +def downgrade(): + op.drop_table("training_certificates") + op.drop_table("training_lesson_progress") + op.drop_table("training_course_enrollments") + op.drop_table("training_lessons") + op.drop_table("training_chapters") + op.drop_table("training_courses") diff --git a/alembic/versions/0048_official_agents.py b/alembic/versions/0048_official_agents.py new file mode 100644 index 0000000..46a11f8 --- /dev/null +++ b/alembic/versions/0048_official_agents.py @@ -0,0 +1,57 @@ +"""官方预置智能体配置表(管理后台可配置,替代硬编码 AGENT_SEED) + +Revision ID: 0048 +Revises: 0047 +Create Date: 2026-09-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0048" +down_revision = "0047" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "official_agents", + sa.Column("id", sa.String(64), primary_key=True), + sa.Column("name", sa.String(200), default=""), + sa.Column("description", sa.Text, default=""), + sa.Column("language", sa.String(20), default="zh"), + sa.Column("model_name", sa.String(100), default=""), + sa.Column("template_type", sa.String(20), default="common"), + sa.Column("deletable", sa.Boolean, default=True), + sa.Column("use_fixed_soul", sa.Boolean, default=False), + sa.Column("enabled", sa.Boolean, default=True), + sa.Column("sort_order", sa.Integer, default=0), + sa.Column("created_at", sa.String(32), default=""), + sa.Column("updated_at", sa.String(32), default=""), + ) + # 迁移原硬编码 AGENT_SEED 为默认官方智能体配置(仅空表时插入)。 + conn = op.get_bind() + cnt = conn.execute(sa.text("SELECT COUNT(*) FROM official_agents")).scalar() + if not cnt: + conn.execute( + sa.text( + """INSERT INTO official_agents + (id, name, description, language, model_name, template_type, + deletable, use_fixed_soul, enabled, sort_order, created_at, updated_at) + VALUES + (:id1, :n1, :d1, 'zh', '', 'common', 0, 1, 1, 1, '', ''), + (:id2, :n2, :d2, 'zh', '', 'qa', 1, 0, 1, 2, '', '')""" + ), + { + "id1": "pine_agents_official_001", + "n1": "小园", + "d1": "默认助手,处理和园区、创业、政策等相关工作", + "id2": "pine_agents_official_002", + "n2": "问答助手", + "d2": "内置 PineAgents 设置问答助手,本地配置在 `PINEAGENTS_WORKING_DIR` 下,并提供文档。建议在回答前阅读文件;在此工作区外使用绝对路径编写代码。", + }, + ) + + +def downgrade(): + op.drop_table("official_agents") diff --git a/alembic/versions/0049_official_agents_scope_docs.py b/alembic/versions/0049_official_agents_scope_docs.py new file mode 100644 index 0000000..a4a2e93 --- /dev/null +++ b/alembic/versions/0049_official_agents_scope_docs.py @@ -0,0 +1,35 @@ +"""官方智能体:身份级别(scope) + 四份文档双段配置(soul/profile/heartbeat/memory) + +- scope:opc(桌面端)/park(园区端)/operator(运营端),逗号分隔多选,all=全部; + 控制该官方智能体下发到哪些端(/agent-bootstrap 按 scope 过滤)。 +- soul/profile/heartbeat/memory:JSON 字符串 {"user": 用户可编辑段, "server": 服务端隐藏注入段}。 + user 段物化为用户可见可编辑的 .md;server 段物化为隐藏文件并注入 system prompt(用户不可见不可改)。 + +Revision ID: 0049 +Revises: 0048 +Create Date: 2026-09-03 +""" +from alembic import op +import sqlalchemy as sa + +revision = "0049" +down_revision = "0048" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("official_agents", sa.Column("scope", sa.String(60), server_default="opc")) + # 注意:MySQL TEXT 列不允许 DEFAULT,文档列允许 NULL,由应用层以 "{}" 兜底。 + op.add_column("official_agents", sa.Column("soul", sa.Text, nullable=True)) + op.add_column("official_agents", sa.Column("profile", sa.Text, nullable=True)) + op.add_column("official_agents", sa.Column("heartbeat", sa.Text, nullable=True)) + op.add_column("official_agents", sa.Column("memory", sa.Text, nullable=True)) + + +def downgrade(): + op.drop_column("official_agents", "scope") + op.drop_column("official_agents", "soul") + op.drop_column("official_agents", "profile") + op.drop_column("official_agents", "heartbeat") + op.drop_column("official_agents", "memory") diff --git a/app/api/routers/auth.py b/app/api/routers/auth.py index 4cf7ac1..17b67ec 100644 --- a/app/api/routers/auth.py +++ b/app/api/routers/auth.py @@ -9,6 +9,7 @@ from __future__ import annotations import base64 import json +import logging import re import secrets @@ -39,6 +40,8 @@ from ...infrastructure.repositories import Database from ...services import sms, wechat from ...domain.account_types import account_type, permission_role, label as account_type_label +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/auth", tags=["auth"]) # 扫码登录会话(内存 + TTL;生产可换 Redis) @@ -217,11 +220,13 @@ async def login(req: LoginRequest, db: Database = Depends(get_db)): detail=f"login {user['username']} (account_type={profile.get('account_type', '')})", user_id=user["id"], ) - return LoginResponse( + resp = LoginResponse( token=token_record["token"], identities=[], **profile, ) + await _inject_mqtt_credentials(resp, user["id"]) + return resp @router.post("/register", response_model=LoginResponse, summary="注册") @@ -249,11 +254,13 @@ async def register(req: RegisterRequest, request: Request, db: Database = Depend ) token_record = await _issue_token(db, user, req.expires_in, identity=None) profile = await _profile_for(user, None, db) - return LoginResponse( + resp = LoginResponse( token=token_record["token"], identities=[], **profile, ) + await _inject_mqtt_credentials(resp, user["id"]) + return resp @router.get("/status", response_model=AuthStatusResponse, summary="认证状态") @@ -413,14 +420,31 @@ async def _login_response_for_user(db: Database, user: dict) -> LoginResponse: """统一登录响应:令牌角色 = 账号单一角色(users.role → account_type)。 同一账号无论 手机号/微信/小程序 哪个方式登录,角色与权限均一致(= 账号密码登录结果)。 + 登录时同步生成用户 MQTT 凭证并下发(桌面端直连 EMQX broker)。 """ token_record = await _issue_token(db, user, None, identity=None) profile = await _profile_for(user, None, db) - return LoginResponse( + resp = LoginResponse( token=token_record["token"], identities=[], **profile, ) + await _inject_mqtt_credentials(resp, user["id"]) + return resp + + +async def _inject_mqtt_credentials(resp: LoginResponse, user_id: str) -> None: + """登录响应注入 MQTT 凭证:im-service 重新生成一次性密码并同步 EMQX。 + + 失败不阻塞登录(桌面端可回退到按需拉取 /im/mqtt/credentials)。 + """ + try: + from ...im import client as im_client + mqtt_creds = await im_client.internal_mqtt_credentials(user_id) + if mqtt_creds and mqtt_creds.get("broker_ws_url"): + resp.mqtt = mqtt_creds + except Exception: # noqa: BLE001 + logger.warning("登录时 MQTT 凭证生成失败(不阻塞登录): %s", user_id) @router.post("/send-code", response_model=SendCodeResponse, summary="发送短信验证码") diff --git a/app/api/routers/bootstrap.py b/app/api/routers/bootstrap.py index bc76689..7bb9ac2 100644 --- a/app/api/routers/bootstrap.py +++ b/app/api/routers/bootstrap.py @@ -1,32 +1,78 @@ # -*- coding: utf-8 -*- """智能体引导(bootstrap)路由。 -返回"所有用户首次启动都必须初始化的智能体"定义 —— 由服务端统一控制。 -这是全局定义(对所有用户相同),因此**公开**(无需登录),本地据此在首次启动时 -初始化本地 workspace,避免本地硬编码默认智能体。 +返回"所有用户首次启动 / 每次登录都必须初始化的官方预置智能体"定义 —— +由运营端管理后台配置(official_agents 表),不再硬编码。 +这是全局定义(对所有用户相同),因此**公开**(无需登录),本地据此在首次启动 +和每次登录时初始化本地 workspace,避免本地硬编码默认智能体。 + +支持 ``?scope=`` 过滤身份级别:opc(桌面端)/park(园区端)/operator(运营端)/all。 +默认 opc(桌面端是主要消费方;旧客户端无参请求仍返回桌面端集合)。 """ from __future__ import annotations -from fastapi import APIRouter +import json as _json -from ...infrastructure.repositories import AGENT_SEED +from fastapi import APIRouter, Depends +from starlette.requests import Request + +from ..dependencies import get_db +from ...infrastructure.repositories import Database router = APIRouter(tags=["bootstrap"]) +_DOC_KEYS = ("soul", "profile", "heartbeat", "memory") -@router.get("/agent-bootstrap", summary="必初始化智能体定义") -async def agent_bootstrap(): - """返回服务端定义的必初始化智能体(id/name/desc/lang/template_type/deletable)。""" - return [ - { - "id": seed["id"], - "name": seed["name"], - "description": seed.get("description", ""), - "language": seed.get("language", "zh"), - "model_name": seed.get("model_name", ""), - "template_type": seed.get("template_type", "default"), - "deletable": seed.get("deletable", True), - "use_fixed_soul": seed.get("use_fixed_soul", False), - } - for seed in AGENT_SEED - ] + +def _parse_docs(raw: str) -> dict: + """解析文档 JSON 字段;失败时返回空对象(不影响下发)。""" + try: + data = _json.loads(raw or "{}") + except (ValueError, TypeError): + return {} + if not isinstance(data, dict): + return {} + return { + "user": str(data.get("user", "") or ""), + "server": str(data.get("server", "") or ""), + } + + +def _match_scope(agent_scope: str, requested: str) -> bool: + """agent.scope 是否覆盖请求 scope:all 覆盖一切;逗号分隔列表含请求值。""" + agent_scope = (agent_scope or "opc").strip() + if not agent_scope or agent_scope == "all": + return True + return requested in [s.strip() for s in agent_scope.split(",")] + + +@router.get("/agent-bootstrap", summary="必初始化官方智能体定义") +async def agent_bootstrap( + request: Request, + db: Database = Depends(get_db), +): + """返回服务端配置的官方预置智能体(启用中),供桌面端登录/启动同步。""" + scope = (request.query_params.get("scope") or "opc").strip() or "opc" + if scope == "all": + scope = "opc" # all 请求场景按全量:下放时以列表值过滤 + seeds = await db.official_agents.active() + out = [] + for seed in seeds: + if not _match_scope(seed.get("scope", "opc"), scope): + continue + docs = {k: _parse_docs(seed.get(k) or "{}") for k in _DOC_KEYS} + out.append( + { + "id": seed["id"], + "name": seed["name"], + "description": seed.get("description", ""), + "language": seed.get("language", "zh"), + "model_name": seed.get("model_name", ""), + "template_type": seed.get("template_type", "common"), + "deletable": seed.get("deletable", True), + "use_fixed_soul": seed.get("use_fixed_soul", False), + "scope": seed.get("scope", "opc"), + "documents": docs, + } + ) + return out diff --git a/app/api/routers/rbac_agents.py b/app/api/routers/rbac_agents.py new file mode 100644 index 0000000..26d8a55 --- /dev/null +++ b/app/api/routers/rbac_agents.py @@ -0,0 +1,197 @@ +# -*- coding: utf-8 -*- +"""智能体管理端点(运营端):官方预置智能体 + 全局最高优先级提示词(fixed_soul)。 + +- 官方预置智能体:由管理后台配置(official_agents 表,全局共享、非 per-user), + 替代原硬编码 ``AGENT_SEED``;桌面端每次登录经 ``/agent-bootstrap`` 拉取并同步。 +- 全局提示词:存 ``system_configs``(agent.fixed_soul.zh / agent.fixed_soul.en), + ``/agent-templates`` 优先返回该配置(未配置时回退服务端默认模板文件)。 + +全部要求 operator 角色 + ``menu:admin_agents`` 权限,并写审计日志。 +""" +from __future__ import annotations + +import json as _json + +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel + +from ..dependencies import get_db +from ...rbac import require_permission, write_audit +from ...infrastructure.repositories import Database, new_id +from .templates import FIXED_SOUL_CONFIG_PREFIX + +router = APIRouter(prefix="/admin/agents", tags=["admin-agents"]) + +TEMPLATE_TYPES = ("common", "qa", "local") +# 身份级别(控制下发到哪些端):opc=桌面端 / park=园区端 / operator=运营端 / all=全部 +SCOPES = ("opc", "park", "operator", "all") +DOC_KEYS = ("soul", "profile", "heartbeat", "memory") + + +def _normalize_scope(scope: str) -> str: + """规范化 scope:逗号分隔去重,非法值抛 400。""" + parts = [] + for raw in scope.split(","): + s = raw.strip() + if not s: + continue + if s not in SCOPES: + raise HTTPException( + status_code=400, + detail=f"scope 取值须为 {SCOPES} 之一(逗号分隔多选)", + ) + if s == "all": + return "all" + if s not in parts: + parts.append(s) + return ",".join(parts) or "opc" + + +def _dump_json_field(value: str | dict | None, field: str) -> str: + """把文档配置字段规范化为 JSON 字符串(仅保留 user/server 两个键)。""" + if isinstance(value, str): + if not value.strip(): + return "{}" + try: + value = _json.loads(value) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail=f"{field} 须为 JSON 对象 {{user, server}}") + if value is None: + value = {} + if not isinstance(value, dict): + raise HTTPException(status_code=400, detail=f"{field} 须为 JSON 对象 {{user, server}}") + return _json.dumps( + { + "user": str(value.get("user", "") or ""), + "server": str(value.get("server", "") or ""), + }, + ensure_ascii=False, + ) + + +class OfficialAgentUpsertRequest(BaseModel): + name: str = "" + description: str = "" + language: str = "zh" + model_name: str = "" + template_type: str = "common" + deletable: bool = True + use_fixed_soul: bool = False + scope: str = "opc" + soul: str | dict = "{}" + profile: str | dict = "{}" + heartbeat: str | dict = "{}" + memory: str | dict = "{}" + enabled: bool = True + sort_order: int = 0 + + +class FixedSoulRequest(BaseModel): + zh: str = "" + en: str = "" + + +# ── 官方预置智能体 ──────────────────────────────────────────────────────── +@router.get("/official", summary="官方预置智能体列表") +async def list_official_agents( + db: Database = Depends(get_db), + _u: dict = Depends(require_permission("menu:admin_agents")), +): + return await db.official_agents.all() + + +@router.post("/official", summary="创建官方预置智能体") +async def create_official_agent( + req: OfficialAgentUpsertRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("menu:admin_agents")), +): + if not req.name.strip(): + raise HTTPException(status_code=400, detail="智能体名称不能为空") + if req.template_type not in TEMPLATE_TYPES: + raise HTTPException(status_code=400, detail=f"template_type 须为 {TEMPLATE_TYPES} 之一") + scope = _normalize_scope(req.scope) + agent = await db.official_agents.create( + new_id("official_agent"), + req.name, + description=req.description, + language=req.language, + model_name=req.model_name, + template_type=req.template_type, + deletable=req.deletable, + use_fixed_soul=req.use_fixed_soul, + scope=scope, + soul=_dump_json_field(req.soul, "soul"), + profile=_dump_json_field(req.profile, "profile"), + heartbeat=_dump_json_field(req.heartbeat, "heartbeat"), + memory=_dump_json_field(req.memory, "memory"), + enabled=req.enabled, + sort_order=req.sort_order, + ) + await write_audit(db, action="agent.official.create", resource="official_agent", + resource_id=agent["id"], detail=req.name, user=actor, request=request) + return agent + + +@router.put("/official/{agent_id}", summary="更新官方预置智能体") +async def update_official_agent( + agent_id: str, + req: OfficialAgentUpsertRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("menu:admin_agents")), +): + if req.template_type not in TEMPLATE_TYPES: + raise HTTPException(status_code=400, detail=f"template_type 须为 {TEMPLATE_TYPES} 之一") + payload = req.model_dump() + payload["scope"] = _normalize_scope(req.scope) + for k in DOC_KEYS: + payload[k] = _dump_json_field(payload.get(k), k) + agent = await db.official_agents.update(agent_id, payload) + if agent is None: + raise HTTPException(status_code=404, detail="官方智能体不存在") + await write_audit(db, action="agent.official.update", resource="official_agent", + resource_id=agent_id, detail=req.name, user=actor, request=request) + return agent + + +@router.delete("/official/{agent_id}", summary="删除官方预置智能体") +async def delete_official_agent( + agent_id: str, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("menu:admin_agents")), +): + ok = await db.official_agents.delete(agent_id) + if not ok: + raise HTTPException(status_code=404, detail="官方智能体不存在") + await write_audit(db, action="agent.official.delete", resource="official_agent", + resource_id=agent_id, detail=agent_id, user=actor, request=request) + return {"ok": True} + + +# ── 全局最高优先级提示词(fixed_soul) ───────────────────────────────────── +@router.get("/fixed-soul", summary="全局最高优先级提示词") +async def get_fixed_soul( + db: Database = Depends(get_db), + _u: dict = Depends(require_permission("menu:admin_agents")), +): + return { + "zh": await db.config.get(f"{FIXED_SOUL_CONFIG_PREFIX}.zh") or "", + "en": await db.config.get(f"{FIXED_SOUL_CONFIG_PREFIX}.en") or "", + } + + +@router.put("/fixed-soul", summary="更新全局最高优先级提示词") +async def set_fixed_soul( + req: FixedSoulRequest, + request: Request, + db: Database = Depends(get_db), + actor: dict = Depends(require_permission("menu:admin_agents")), +): + await db.config.set(f"{FIXED_SOUL_CONFIG_PREFIX}.zh", req.zh, "全局最高优先级提示词(zh)") + await db.config.set(f"{FIXED_SOUL_CONFIG_PREFIX}.en", req.en, "全局最高优先级提示词(en)") + await write_audit(db, action="agent.fixed_soul.update", resource="system_config", + resource_id="agent.fixed_soul", detail="zh/en", user=actor, request=request) + return {"ok": True} diff --git a/app/api/routers/rbac_public.py b/app/api/routers/rbac_public.py new file mode 100644 index 0000000..c531631 --- /dev/null +++ b/app/api/routers/rbac_public.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""公共配置端点:无需登录或特殊角色即可读取的站点级配置。 + +只暴露 ``site.`` 前缀的配置(如 Header 广告词),避免泄露短信密钥等敏感配置。 +写入仍走 ``/admin/config/{key}``(要求 operator + config.manage 权限)。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends + +from ..dependencies import get_db +from ...infrastructure.repositories import Database + +router = APIRouter(tags=["public"]) + + +@router.get("/site/config", summary="公共站点配置列表(所有用户可读)") +async def list_site_config( + db: Database = Depends(get_db), +): + """返回所有 ``site.`` 前缀的配置项,供桌面端 / 小程序 / web 端读取。 + + 不要求登录或 operator 角色;敏感配置(短信密钥等)不会出现在这里。 + """ + all_config = await db.config.all() + return [item for item in all_config if str(item.get("key", "")).startswith("site.")] diff --git a/app/api/routers/rbac_training.py b/app/api/routers/rbac_training.py new file mode 100644 index 0000000..934a1c3 --- /dev/null +++ b/app/api/routers/rbac_training.py @@ -0,0 +1,624 @@ +"""培训与技能认证体系 API 路由。 + +覆盖:课程浏览、报名、学习进度、结业证书、运营端课程管理。 +完整闭环:参加培训 → 完成学习 → 通过考核 → 获得证书 → 授予徽章 → 信用加分。 +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel +from typing import Optional + +from ...infrastructure.repositories import Database, new_id, utcnow_iso +from ...infrastructure.models import ( + TrainingCourse, TrainingChapter, TrainingLesson, + TrainingCourseEnrollment, TrainingLessonProgress, TrainingCertificate, +) +import json + +router = APIRouter() + + +# ── 依赖 ────────────────────────────────────────────────────────────── + +async def get_db() -> Database: + db = Database() + try: + yield db + finally: + await db.close() + + +def get_current_user(authorization: str = Query(default="")) -> dict: + """简化的用户获取(实际项目中从 JWT 解析)。""" + # 这里返回一个模拟用户,实际应从 auth 依赖获取 + return {"id": "demo_user", "username": "demo"} + + +# ── 请求模型 ────────────────────────────────────────────────────────── + +class EnrollRequest(BaseModel): + pass + + +class LessonProgressRequest(BaseModel): + watch_seconds: int = 0 + + +class LessonCompleteRequest(BaseModel): + quiz_score: Optional[int] = None + + +class CourseCreateRequest(BaseModel): + title: str + subtitle: str = "" + description: str = "" + cover_url: str = "" + category: str = "" + level: str = "beginner" + price: int = 0 + duration_minutes: int = 0 + instructor_name: str = "" + instructor_title: str = "" + instructor_avatar: str = "" + tags: list = [] + prerequisites: str = "" + learning_outcomes: str = "" + status: str = "draft" + is_featured: int = 0 + sort_order: int = 0 + + +class ChapterCreateRequest(BaseModel): + course_id: str + title: str + description: str = "" + sort_order: int = 0 + + +class LessonCreateRequest(BaseModel): + chapter_id: str + course_id: str + title: str + type: str = "video" + content: str = "" + duration_minutes: int = 0 + sort_order: int = 0 + is_free: int = 0 + is_exam: int = 0 + + +# ── 课程分类常量 ─────────────────────────────────────────────────────── + +COURSE_CATEGORIES = [ + {"code": "ui_design", "name": "UI设计", "icon": "PaletteFilled"}, + {"code": "graphic_design", "name": "平面设计", "icon": "ImageFilled"}, + {"code": "frontend_dev", "name": "前端开发", "icon": "CodeFilled"}, + {"code": "backend_dev", "name": "后端开发", "icon": "ServerFilled"}, + {"code": "copywriting", "name": "文案写作", "icon": "PenFilled"}, + {"code": "translation", "name": "翻译", "icon": "LanguagesFilled"}, + {"code": "data_analysis", "name": "数据分析", "icon": "ChartBarFilled"}, + {"code": "project_management", "name": "项目管理", "icon": "TrendingUpFilled"}, +] + +LEVEL_LABELS = { + "beginner": "入门", + "intermediate": "进阶", + "advanced": "高级", + "expert": "专家", +} + + +# ══════════════════════════════════════════════════════════════════════ +# 公开:课程浏览 +# ══════════════════════════════════════════════════════════════════════ + +@router.get("/courses") +async def list_courses( + category: str = "", + level: str = "", + search: str = "", + featured_only: bool = False, + db: Database = Depends(get_db), +): + """课程列表(公开,仅返回已发布课程)。""" + courses = await db.courses.list( + category=category, level=level, status="published", + search=search, featured_only=featured_only, + ) + # 附加分类名称和等级名称 + for c in courses: + cat = next((x for x in COURSE_CATEGORIES if x["code"] == c["category"]), None) + c["category_name"] = cat["name"] if cat else c["category"] + c["level_name"] = LEVEL_LABELS.get(c["level"], c["level"]) + return {"items": courses, "total": len(courses)} + + +@router.get("/courses/categories") +async def list_course_categories(db: Database = Depends(get_db)): + """课程分类列表(含课程数统计)。""" + courses = await db.courses.list(status="published") + cat_counts = {} + for c in courses: + cat_counts[c["category"]] = cat_counts.get(c["category"], 0) + 1 + result = [] + for cat in COURSE_CATEGORIES: + result.append({ + **cat, + "course_count": cat_counts.get(cat["code"], 0), + }) + return {"items": result} + + +@router.get("/courses/{course_id}") +async def get_course_detail(course_id: str, db: Database = Depends(get_db)): + """课程详情(含章节和课时列表)。""" + course = await db.courses.get(course_id) + if not course: + raise HTTPException(status_code=404, detail="课程不存在") + cat = next((x for x in COURSE_CATEGORIES if x["code"] == course["category"]), None) + course["category_name"] = cat["name"] if cat else course["category"] + course["level_name"] = LEVEL_LABELS.get(course["level"], course["level"]) + + # 获取章节和课时 + chapters = await db.course_chapters.list_by_course(course_id) + for ch in chapters: + ch["lessons"] = await db.course_lessons.list_by_chapter(ch["id"]) + course["chapters"] = chapters + course["total_lessons"] = sum(len(ch["lessons"]) for ch in chapters) + return course + + +# ══════════════════════════════════════════════════════════════════════ +# 我的课程:报名、学习进度 +# ══════════════════════════════════════════════════════════════════════ + +@router.get("/me/courses") +async def list_my_courses( + status: str = "", + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + """我报名的课程列表。""" + enrollments = await db.course_enrollments.list_by_user(user["id"], status=status) + # 附加课程信息 + for e in enrollments: + course = await db.courses.get(e["course_id"]) + if course: + e["course"] = course + cat = next((x for x in COURSE_CATEGORIES if x["code"] == course["category"]), None) + e["course"]["category_name"] = cat["name"] if cat else course["category"] + return {"items": enrollments, "total": len(enrollments)} + + +@router.post("/courses/{course_id}/enroll") +async def enroll_course( + course_id: str, + req: EnrollRequest, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + """报名课程(免费课程直接报名,付费课程需先支付)。""" + course = await db.courses.get(course_id) + if not course: + raise HTTPException(status_code=404, detail="课程不存在") + if course["status"] != "published": + raise HTTPException(status_code=400, detail="课程未发布") + + # 检查是否已报名 + existing = await db.course_enrollments.get(user["id"], course_id) + if existing: + return {"ok": True, "item": existing, "already_enrolled": True} + + # 付费课程需要支付(这里简化,实际应对接支付) + if course["price"] > 0: + # TODO: 对接微信支付,返回支付参数 + return {"ok": False, "message": "付费课程需先完成支付", "price": course["price"]} + + # 统计课程总课时数 + total_lessons = await db.course_lessons.count_by_course(course_id) + + enrollment = await db.course_enrollments.create( + user_id=user["id"], + course_id=course_id, + status="enrolled", + progress_percent=0, + completed_lessons=0, + total_lessons=total_lessons, + price_paid=0, + ) + # 增加课程报名数 + await db.courses.increment_enroll(course_id) + return {"ok": True, "item": enrollment, "already_enrolled": False} + + +@router.get("/me/courses/{course_id}/progress") +async def get_course_progress( + course_id: str, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + """课程学习进度详情(含每个课时的完成状态)。""" + enrollment = await db.course_enrollments.get(user["id"], course_id) + if not enrollment: + raise HTTPException(status_code=404, detail="未报名该课程") + + # 获取所有课时的进度 + lessons = await db.course_lessons.list_by_course(course_id) + progress_list = await db.course_lesson_progress.list_by_enrollment(enrollment["id"]) + progress_map = {p["lesson_id"]: p for p in progress_list} + + lesson_statuses = [] + for lesson in lessons: + p = progress_map.get(lesson["id"]) + lesson_statuses.append({ + "lesson_id": lesson["id"], + "title": lesson["title"], + "type": lesson["type"], + "status": p["status"] if p else "not_started", + "watch_seconds": p["watch_seconds"] if p else 0, + "completed_at": p["completed_at"] if p else None, + }) + + return { + "enrollment": enrollment, + "lessons": lesson_statuses, + "completed_count": enrollment["completed_lessons"], + "total_count": enrollment["total_lessons"], + "progress_percent": enrollment["progress_percent"], + } + + +@router.post("/courses/lessons/{lesson_id}/start") +async def start_lesson( + lesson_id: str, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + """开始学习课时。""" + lesson = await db.course_lessons.get(lesson_id) + if not lesson: + raise HTTPException(status_code=404, detail="课时不存在") + + enrollment = await db.course_enrollments.get(user["id"], lesson["course_id"]) + if not enrollment: + raise HTTPException(status_code=403, detail="请先报名课程") + + progress = await db.course_lesson_progress.get_or_create( + enrollment["id"], user["id"], lesson_id, lesson["course_id"], + ) + if progress["status"] == "not_started": + progress = await db.course_lesson_progress.update( + progress["id"], {"status": "in_progress", "started_at": utcnow_iso()}, + ) + # 更新报名记录的上次学习 + await db.course_enrollments.update( + enrollment["id"], + {"last_lesson_id": lesson_id, "last_studied_at": utcnow_iso(), "status": "in_progress"}, + ) + return {"ok": True, "item": progress, "lesson": lesson} + + +@router.post("/courses/lessons/{lesson_id}/progress") +async def update_lesson_progress( + lesson_id: str, + req: LessonProgressRequest, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + """更新课时学习进度(watch_seconds 心跳上报)。""" + lesson = await db.course_lessons.get(lesson_id) + if not lesson: + raise HTTPException(status_code=404, detail="课时不存在") + + enrollment = await db.course_enrollments.get(user["id"], lesson["course_id"]) + if not enrollment: + raise HTTPException(status_code=403, detail="请先报名课程") + + progress = await db.course_lesson_progress.get_or_create( + enrollment["id"], user["id"], lesson_id, lesson["course_id"], + ) + progress = await db.course_lesson_progress.update( + progress["id"], + {"watch_seconds": req.watch_seconds, "status": "in_progress"}, + ) + # 更新总学习时长 + total_watch = enrollment["total_watch_seconds"] + 30 # 每次心跳+30秒 + await db.course_enrollments.update(enrollment["id"], {"total_watch_seconds": total_watch}) + return {"ok": True, "item": progress} + + +@router.post("/courses/lessons/{lesson_id}/complete") +async def complete_lesson( + lesson_id: str, + req: LessonCompleteRequest, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + """完成课时(自动更新课程进度)。""" + lesson = await db.course_lessons.get(lesson_id) + if not lesson: + raise HTTPException(status_code=404, detail="课时不存在") + + enrollment = await db.course_enrollments.get(user["id"], lesson["course_id"]) + if not enrollment: + raise HTTPException(status_code=403, detail="请先报名课程") + + progress = await db.course_lesson_progress.get_or_create( + enrollment["id"], user["id"], lesson_id, lesson["course_id"], + ) + if progress["status"] != "completed": + progress = await db.course_lesson_progress.update( + progress["id"], + {"status": "completed", "completed_at": utcnow_iso(), "quiz_score": req.quiz_score or 0}, + ) + + # 重新计算课程进度 + completed = await db.course_lesson_progress.count_completed(enrollment["id"]) + total = enrollment["total_lessons"] or 1 + progress_percent = int((completed / total) * 100) + status = "completed" if progress_percent >= 100 else "in_progress" + await db.course_enrollments.update( + enrollment["id"], + {"completed_lessons": completed, "progress_percent": progress_percent, "status": status}, + ) + return {"ok": True, "item": progress, "course_progress": progress_percent, "completed": completed, "total": total} + + +@router.post("/courses/{course_id}/complete") +async def complete_course( + course_id: str, + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + """完成课程(检查全部课时完成,颁发结业证书)。""" + course = await db.courses.get(course_id) + if not course: + raise HTTPException(status_code=404, detail="课程不存在") + + enrollment = await db.course_enrollments.get(user["id"], course_id) + if not enrollment: + raise HTTPException(status_code=403, detail="请先报名课程") + + # 检查是否所有课时都完成 + completed = await db.course_lesson_progress.count_completed(enrollment["id"]) + total = enrollment["total_lessons"] or 0 + if completed < total: + raise HTTPException(status_code=400, detail=f"还有 {total - completed} 个课时未完成") + + # 检查是否已有证书 + existing_cert = await db.course_certificates.get_by_user_course(user["id"], course_id) + if existing_cert: + return {"ok": True, "certificate": existing_cert, "already_issued": True} + + # 生成证书编号 + now = utcnow_iso() + date_str = now[:10].replace("-", "") + cert_no = f"OPC-CC-{date_str}-{new_id('')[:4].upper()}" + + certificate = await db.course_certificates.create( + certificate_no=cert_no, + user_id=user["id"], + course_id=course_id, + course_title=course["title"], + instructor_name=course["instructor_name"], + duration_minutes=enrollment["total_watch_seconds"] // 60, + final_score=0, + issued_at=now, + is_verified=1, + ) + + # 更新报名状态 + await db.course_enrollments.update( + enrollment["id"], + {"status": "completed", "completed_at": now, "progress_percent": 100}, + ) + + # TODO: 授予徽章(course_first 等)+ 信用分加分(+10) + + return {"ok": True, "certificate": certificate, "already_issued": False} + + +# ══════════════════════════════════════════════════════════════════════ +# 结业证书 +# ══════════════════════════════════════════════════════════════════════ + +@router.get("/me/certificates") +async def list_my_certificates( + db: Database = Depends(get_db), + user: dict = Depends(get_current_user), +): + """我的结业证书列表。""" + certs = await db.course_certificates.list_by_user(user["id"]) + return {"items": certs, "total": len(certs)} + + +@router.get("/certificates/{certificate_id}") +async def get_certificate(certificate_id: str, db: Database = Depends(get_db)): + """证书详情(公开可验证)。""" + cert = await db.course_certificates.get(certificate_id) + if not cert: + raise HTTPException(status_code=404, detail="证书不存在") + return cert + + +@router.get("/certificates/verify/{certificate_no}") +async def verify_certificate(certificate_no: str, db: Database = Depends(get_db)): + """公开验证证书。""" + cert = await db.course_certificates.get_by_no(certificate_no) + if not cert: + return {"valid": False, "message": "证书不存在"} + return {"valid": True, "certificate": cert} + + +# ══════════════════════════════════════════════════════════════════════ +# 运营端:课程管理 +# ══════════════════════════════════════════════════════════════════════ + +@router.get("/admin/courses") +async def admin_list_courses( + status: str = "", + category: str = "", + db: Database = Depends(get_db), +): + """运营端:课程列表(含草稿)。""" + courses = await db.courses.list(category=category, status=status if status else "") + return {"items": courses, "total": len(courses)} + + +@router.post("/admin/courses") +async def admin_create_course( + req: CourseCreateRequest, + db: Database = Depends(get_db), +): + """运营端:创建课程。""" + course = await db.courses.create(**req.model_dump()) + return {"ok": True, "item": course} + + +@router.put("/admin/courses/{course_id}") +async def admin_update_course( + course_id: str, + req: CourseCreateRequest, + db: Database = Depends(get_db), +): + """运营端:编辑课程。""" + course = await db.courses.update(course_id, req.model_dump()) + if not course: + raise HTTPException(status_code=404, detail="课程不存在") + return {"ok": True, "item": course} + + +@router.delete("/admin/courses/{course_id}") +async def admin_delete_course(course_id: str, db: Database = Depends(get_db)): + """运营端:删除课程(软删除,改为 archived)。""" + course = await db.courses.update(course_id, {"status": "archived"}) + if not course: + raise HTTPException(status_code=404, detail="课程不存在") + return {"ok": True, "item": course} + + +@router.post("/admin/courses/{course_id}/publish") +async def admin_publish_course(course_id: str, db: Database = Depends(get_db)): + """运营端:发布课程。""" + course = await db.courses.update(course_id, {"status": "published"}) + if not course: + raise HTTPException(status_code=404, detail="课程不存在") + return {"ok": True, "item": course} + + +# ── 章节管理 ────────────────────────────────────────────────────────── + +@router.post("/admin/courses/{course_id}/chapters") +async def admin_create_chapter( + course_id: str, + req: ChapterCreateRequest, + db: Database = Depends(get_db), +): + """运营端:添加章节。""" + chapter = await db.course_chapters.create(course_id=course_id, **req.model_dump(exclude={"course_id"})) + # 更新课程章节数 + course = await db.courses.get(course_id) + if course: + chapters = await db.course_chapters.list_by_course(course_id) + await db.courses.update(course_id, {"chapter_count": len(chapters)}) + return {"ok": True, "item": chapter} + + +@router.put("/admin/chapters/{chapter_id}") +async def admin_update_chapter( + chapter_id: str, + req: ChapterCreateRequest, + db: Database = Depends(get_db), +): + """运营端:编辑章节。""" + chapter = await db.course_chapters.update(chapter_id, req.model_dump()) + if not chapter: + raise HTTPException(status_code=404, detail="章节不存在") + return {"ok": True, "item": chapter} + + +@router.delete("/admin/chapters/{chapter_id}") +async def admin_delete_chapter(chapter_id: str, db: Database = Depends(get_db)): + """运营端:删除章节。""" + chapter = await db.course_chapters.get(chapter_id) + if not chapter: + raise HTTPException(status_code=404, detail="章节不存在") + course_id = chapter["course_id"] + await db.course_chapters.delete(chapter_id) + # 更新课程章节数 + chapters = await db.course_chapters.list_by_course(course_id) + await db.courses.update(course_id, {"chapter_count": len(chapters)}) + return {"ok": True} + + +# ── 课时管理 ────────────────────────────────────────────────────────── + +@router.post("/admin/chapters/{chapter_id}/lessons") +async def admin_create_lesson( + chapter_id: str, + req: LessonCreateRequest, + db: Database = Depends(get_db), +): + """运营端:添加课时。""" + lesson = await db.course_lessons.create(chapter_id=chapter_id, **req.model_dump(exclude={"chapter_id"})) + # 更新课程课时数 + course = await db.courses.get(req.course_id) + if course: + total = await db.course_lessons.count_by_course(req.course_id) + await db.courses.update(req.course_id, {"lesson_count": total}) + return {"ok": True, "item": lesson} + + +@router.put("/admin/lessons/{lesson_id}") +async def admin_update_lesson( + lesson_id: str, + req: LessonCreateRequest, + db: Database = Depends(get_db), +): + """运营端:编辑课时。""" + lesson = await db.course_lessons.update(lesson_id, req.model_dump()) + if not lesson: + raise HTTPException(status_code=404, detail="课时不存在") + return {"ok": True, "item": lesson} + + +@router.delete("/admin/lessons/{lesson_id}") +async def admin_delete_lesson(lesson_id: str, db: Database = Depends(get_db)): + """运营端:删除课时。""" + lesson = await db.course_lessons.get(lesson_id) + if not lesson: + raise HTTPException(status_code=404, detail="课时不存在") + course_id = lesson["course_id"] + await db.course_lessons.delete(lesson_id) + # 更新课程课时数 + total = await db.course_lessons.count_by_course(course_id) + await db.courses.update(course_id, {"lesson_count": total}) + return {"ok": True} + + +# ── 运营端统计 ──────────────────────────────────────────────────────── + +@router.get("/admin/courses/{course_id}/enrollments") +async def admin_course_enrollments( + course_id: str, + db: Database = Depends(get_db), +): + """运营端:课程报名统计。""" + enrollments = await db.course_enrollments.list_by_course(course_id) + completed = sum(1 for e in enrollments if e["status"] == "completed") + in_progress = sum(1 for e in enrollments if e["status"] == "in_progress") + return { + "items": enrollments, + "total": len(enrollments), + "completed": completed, + "in_progress": in_progress, + "enrolled": len(enrollments) - completed - in_progress, + } + + +@router.get("/admin/certificates") +async def admin_list_certificates(db: Database = Depends(get_db)): + """运营端:证书列表。""" + certs = await db.course_certificates.list_all() + return {"items": certs, "total": len(certs)} diff --git a/app/api/routers/templates.py b/app/api/routers/templates.py index ae15f6e..a54028f 100644 --- a/app/api/routers/templates.py +++ b/app/api/routers/templates.py @@ -11,13 +11,17 @@ from pathlib import Path from fastapi import APIRouter, Depends from ... import config -from ..dependencies import get_current_user +from ..dependencies import get_current_user, get_db +from ...infrastructure.repositories import Database router = APIRouter(prefix="/agent-templates", tags=["agent-templates"]) # 模板类型 → 目录名映射("common" 即 TEMPLATES_DIR 根下的语言目录)。 _TEMPLATE_TYPES = ("common", "qa", "local") +# 全局官方注入的最高优先级提示词(fixed_soul)→ system_configs 存储键。 +FIXED_SOUL_CONFIG_PREFIX = "agent.fixed_soul" + def _read_type_dir(base: Path, type_name: str) -> dict[str, dict[str, str]]: root = base if type_name == "common" else base / type_name @@ -37,26 +41,41 @@ def _read_type_dir(base: Path, type_name: str) -> dict[str, dict[str, str]]: @router.get("", summary="智能体初始化模板树", response_model=dict) async def get_agent_templates( _user: dict = Depends(get_current_user), + db: Database = Depends(get_db), ): """返回全部初始化模板: ``{template_type: {language: {filename: content}}}`` + 顶层 ``fixed_soul`` - (服务端托管、不可变、最高优先级的 SOUL 片段)。 + (全局官方注入、最高优先级的 SOUL 片段,由管理后台「智能体 → 全局提示词」 + 配置,未配置时回退服务端默认模板文件)。 """ base = config.TEMPLATES_DIR tree = { type_name: _read_type_dir(base, type_name) for type_name in _TEMPLATE_TYPES } - tree["fixed_soul"] = _read_lang_files(base / "fixed_soul") + tree["fixed_soul"] = await _fixed_soul(db, base) return tree -def _read_lang_files(root: Path) -> dict[str, str]: - """读取 ``/.md`` → ``{lang: content}``。""" +async def _fixed_soul(db: Database, base: Path) -> dict[str, str]: + """读取全局 fixed_soul:优先管理平台配置(system_configs),回退模板文件。 + + 管理后台可配置 ``agent.fixed_soul.zh`` / ``agent.fixed_soul.en`` 键。 + """ files: dict[str, str] = {} - if not root.is_dir(): - return files - for md in sorted(root.glob("*.md")): - files[md.stem] = md.read_text(encoding="utf-8") + root = base / "fixed_soul" + if root.is_dir(): + for md in sorted(root.glob("*.md")): + files[md.stem] = md.read_text(encoding="utf-8") + # 平台配置覆盖(仅对已配置的语言生效)。 + for lang, default in files.items(): + cfg = await db.config.get(f"{FIXED_SOUL_CONFIG_PREFIX}.{lang}") + if cfg: + files[lang] = cfg + # 平台新增配置的语言(模板文件缺失时也下发)。 + for lang in ("zh", "en"): + cfg = await db.config.get(f"{FIXED_SOUL_CONFIG_PREFIX}.{lang}") + if cfg and lang not in files: + files[lang] = cfg return files diff --git a/app/api/schemas/auth.py b/app/api/schemas/auth.py index d0c49d7..9955350 100644 --- a/app/api/schemas/auth.py +++ b/app/api/schemas/auth.py @@ -103,6 +103,8 @@ class LoginResponse(UserProfile): token: str = "" identities: list[IdentityInfo] = Field(default_factory=list) + # 登录时生成的 MQTT 连接凭证(直连 EMQX broker 用),可能为 None(EMQX 未配置/失败) + mqtt: dict | None = None class ProfileResponse(UserProfile): """update-profile 成功后的响应;修改凭据时附带新令牌。""" diff --git a/app/config.py b/app/config.py index e9b4e39..cd44310 100644 --- a/app/config.py +++ b/app/config.py @@ -126,6 +126,17 @@ COMPUTE_PUBLIC_BASE = os.environ.get("PINEAGENTS_COMPUTE_PUBLIC_BASE", "").rstri COMPUTE_TIMEOUT = float(os.environ.get("PINEAGENTS_COMPUTE_TIMEOUT", "10")) COMPUTE_RETRIES = int(os.environ.get("PINEAGENTS_COMPUTE_RETRIES", "2")) +# --------------------------------------------------------------------------- +# IM 微服务(opc-im-service,loopback,仅服务) +# 聊天 REST/WS 统一经 server-core 对外:/im/* → IM_BASE_URL/api/v1/*,/im/ws → IM_BASE_URL/ws。 +# 任务群等内部同步用 IM_INTERNAL_TOKEN(与 im-service .env 一致)。 +# --------------------------------------------------------------------------- +IM_BASE_URL = os.environ.get("PINEAGENTS_IM_BASE_URL", "http://127.0.0.1:8101").rstrip("/") +IM_WS_URL = os.environ.get("PINEAGENTS_IM_WS_URL", "ws://127.0.0.1:8101/ws") +IM_INTERNAL_TOKEN = os.environ.get("IM_INTERNAL_TOKEN") or os.environ.get("PINEAGENTS_INTERNAL_TOKEN", "") +IM_TIMEOUT = float(os.environ.get("PINEAGENTS_IM_TIMEOUT", "10")) +IM_RETRIES = int(os.environ.get("PINEAGENTS_IM_RETRIES", "1")) + # --------------------------------------------------------------------------- # 短信验证码(统一登录) # --------------------------------------------------------------------------- diff --git a/app/im/__init__.py b/app/im/__init__.py new file mode 100644 index 0000000..87f80f8 --- /dev/null +++ b/app/im/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""IM 微服务接入:客户端 + 对外桥接路由。""" diff --git a/app/im/client.py b/app/im/client.py new file mode 100644 index 0000000..bbfa748 --- /dev/null +++ b/app/im/client.py @@ -0,0 +1,110 @@ +# -*- coding: utf-8 -*- +"""IM 微服务客户端(opc-im-service,loopback)。 + +- 对外 REST 转发:server-core /im/{path} → im-service /api/v1/{path}(透传用户 JWT) +- 内部同步:任务群 / 园区群 / 客服通知(带 IM_INTERNAL_TOKEN) +- WS 桥接在 router.py 实现 +""" +from __future__ import annotations + +import asyncio + +import httpx + +from .. import config + + +class IMError(Exception): + """IM 服务调用异常。""" + + +def _base() -> str: + return config.IM_BASE_URL.rstrip("/") + + +def _internal_headers() -> dict[str, str]: + if not config.IM_INTERNAL_TOKEN: + return {} + return {"X-IM-Token": config.IM_INTERNAL_TOKEN} + + +async def _request(*, method: str, path: str, headers: dict[str, str] | None = None, + json_body: dict | None = None, params: dict | None = None) -> dict: + """带超时/重试的 IM 服务请求;2xx 返回 JSON,否则抛 IMError。""" + url = f"{_base()}{path}" + last: Exception | None = None + for _ in range(max(1, config.IM_RETRIES + 1)): + try: + async with httpx.AsyncClient(timeout=config.IM_TIMEOUT) as client: + resp = await client.request(method, url, json=json_body, headers=headers, params=params) + if resp.status_code == 200: + try: + return resp.json() if resp.content else {} + except Exception: # noqa: BLE001 + return {} + if resp.status_code < 500: + raise IMError(f"im-service {resp.status_code}: {resp.text[:200]}") + last = IMError(f"im-service {resp.status_code}: {resp.text[:200]}") + except httpx.HTTPError as exc: + last = IMError(f"im-service request failed: {exc}") + except IMError as exc: + last = exc + if " 4" in str(exc) or " 3" in str(exc): + break + await asyncio.sleep(0.2) + raise last or IMError("im-service unavailable") + + +async def forward(api_path: str, *, auth_header: str, method: str = "GET", + body: dict | None = None, params: dict | None = None) -> dict: + """把用户 REST 请求转发到 im-service /api/v1/*,透传 JWT。""" + headers = {"Authorization": auth_header} if auth_header else {} + return await _request(method=method, path=f"/api/v1{api_path}", headers=headers, json_body=body, params=params) + + +# ── 内部同步(业务路由调用,fire-and-forget 不阻断主流程) ───── + +async def sync_task_group(task_id: str, task_title: str = "") -> bool: + """任务发布/接单/中标后自动建/刷新任务群。失败仅记录,不抛给主流程。""" + try: + await _request( + method="POST", path="/internal/task-sync", + headers=_internal_headers(), + json_body={"task_id": task_id, "task_title": task_title}, + ) + return True + except IMError: + return False + + +async def sync_park_group(park_id: str, park_name: str = "") -> bool: + """确保园区公共群存在。""" + try: + await _request( + method="POST", path="/internal/park-sync", + headers=_internal_headers(), + json_body={"park_id": park_id, "park_name": park_name}, + ) + return True + except IMError: + return False + + +async def internal_mqtt_credentials(user_id: str) -> dict: + """登录时生成/更新用户 MQTT 凭证并同步 EMQX(随登录响应下发)。 + + 生成新的一次性密码 → im-service 写 im_mqtt_credentials → 同步 EMQX。 + 返回 dict 含 broker_ws_url/username/password/client_id/topic_prefix/presence_topic。 + """ + return await _request( + method="POST", path="/internal/mqtt-credentials", + headers=_internal_headers(), + json_body={"user_id": str(user_id)}, + ) + + +async def health() -> dict: + try: + return await _request(method="GET", path="/health") + except IMError as exc: + return {"ok": False, "detail": str(exc)} diff --git a/app/im/router.py b/app/im/router.py new file mode 100644 index 0000000..8832762 --- /dev/null +++ b/app/im/router.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +"""IM 对外桥接路由(server-core 作为唯一对外入口)。 + +- ``/im/ws``:WebSocket 桥接到 im-service(透传 JWT) +- ``/im/internal/task-sync|park-sync``:内部同步(供上层按需触发) +- ``/im/{path:path}``:REST 转发到 im-service ``/api/v1/{path}``(透传 JWT) +""" +from __future__ import annotations + +import asyncio +import logging + +from fastapi import APIRouter, HTTPException, Request, WebSocket, WebSocketDisconnect + +from . import client + +log = logging.getLogger("im") + +router = APIRouter(prefix="/im", tags=["im"]) + + +def _auth(request: Request) -> str: + return request.headers.get("Authorization", "") + + +# ── 内部同步(显式优先于 catch-all) ────────────────────────── + +@router.get("/health") +async def im_health(): + try: + data = await client._request(method="GET", path="/health") + data["ok"] = True + return data + except client.IMError as exc: + return {"ok": False, "detail": str(exc)} + + +@router.post("/internal/task-sync") +async def internal_task_sync(request: Request): + body = await _json_body(request) + task_id = (body or {}).get("task_id", "") + if not task_id: + raise HTTPException(status_code=400, detail="task_id 必填") + ok = await client.sync_task_group(task_id, (body or {}).get("task_title", "")) + if not ok: + raise HTTPException(status_code=502, detail="IM 服务不可用") + return {"ok": True} + + +@router.post("/internal/park-sync") +async def internal_park_sync(request: Request): + body = await _json_body(request) + park_id = (body or {}).get("park_id", "") + if not park_id: + raise HTTPException(status_code=400, detail="park_id 必填") + ok = await client.sync_park_group(park_id, (body or {}).get("park_name", "")) + if not ok: + raise HTTPException(status_code=502, detail="IM 服务不可用") + return {"ok": True} + + +# ── REST 转发 ──────────────────────────────────────────────── + +@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"]) +async def im_forward(path: str, request: Request): + if path.startswith("internal/"): + raise HTTPException(status_code=404, detail="unknown internal endpoint") + body = None + if request.method in ("POST", "PUT", "PATCH"): + try: + body = await request.json() + except Exception: # noqa: BLE001 + body = None + # 透传 query 参数(搜索/分页等) + params = dict(request.query_params) or None + try: + return await client.forward( + "/" + path, + auth_header=_auth(request), + method=request.method, + body=body if body is not None else None, + params=params, + ) + except client.IMError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + + +# ── WebSocket 桥接 ─────────────────────────────────────────── + +@router.websocket("/ws") +async def im_ws_bridge(websocket: WebSocket) -> None: + import websockets + + token = websocket.query_params.get("token", "") + await websocket.accept() + im_ws: websockets.WebSocketClientProtocol | None = None + try: + url = f"{client._base()}/ws?token={token}" + im_ws = await websockets.connect(url, max_size=16 * 1024 * 1024) + except Exception as exc: # noqa: BLE001 + log.warning("IM WS 桥接上游连接失败: %s", exc) + await websocket.close(code=1011, reason="IM service unavailable") + return + + async def client_to_im() -> None: + try: + while True: + raw = await websocket.receive_text() + await im_ws.send(raw) + except (WebSocketDisconnect, Exception): # noqa: BLE001 + pass + + async def im_to_client() -> None: + try: + while True: + raw = await im_ws.recv() + await websocket.send_text(raw) + except (websockets.ConnectionClosed, Exception): # noqa: BLE001 + pass + + tasks = [ + asyncio.create_task(client_to_im()), + asyncio.create_task(im_to_client()), + ] + done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) + for t in pending: + t.cancel() + try: + await websocket.close() + except Exception: # noqa: BLE001 + pass + if im_ws is not None: + try: + await im_ws.close() + except Exception: # noqa: BLE001 + pass + + +async def _json_body(request: Request) -> dict | None: + try: + return await request.json() + except Exception: # noqa: BLE001 + return None diff --git a/app/infrastructure/hall_repositories.py b/app/infrastructure/hall_repositories.py index bb2df19..1627721 100644 --- a/app/infrastructure/hall_repositories.py +++ b/app/infrastructure/hall_repositories.py @@ -166,6 +166,10 @@ class OpcServiceRepository: async def create(self, **kw) -> dict: now = utcnow_iso() + # gallery 字段在模型中存储为 gallery_json,需转换 + gallery = kw.pop("gallery", None) + if gallery is not None: + kw["gallery_json"] = json.dumps(gallery, ensure_ascii=False) row = OpcService(id=new_id("svc"), created_at=now, updated_at=now, **kw) self.session.add(row) await self.session.commit() diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index 80330b9..1887fd2 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -208,6 +208,34 @@ class Agent(Base): updated_at: Mapped[str] = mapped_column(String, default="") +class OfficialAgent(Base): + """官方预置智能体配置(管理后台可配置,全局共享,非 per-user)。 + + 替代原硬编码 ``AGENT_SEED``:官方预置智能体由运营端管理后台配置, + 桌面端每次登录经 ``/agent-bootstrap`` 拉取并按此初始化本地 workspace。 + """ + + __tablename__ = "official_agents" + + id: Mapped[str] = mapped_column(String, primary_key=True) + name: Mapped[str] = mapped_column(String, default="") + description: Mapped[str] = mapped_column(Text, default="") + language: Mapped[str] = mapped_column(String, default="zh") + model_name: Mapped[str] = mapped_column(String, default="") + template_type: Mapped[str] = mapped_column(String, default="common") # common/qa/local + deletable: Mapped[bool] = mapped_column(Boolean, default=True) # 是否允许用户在桌面端删除 + use_fixed_soul: Mapped[bool] = mapped_column(Boolean, default=False) # 是否强制使用全局 fixed_soul + scope: Mapped[str] = mapped_column(String, default="opc") # 身份级别:opc(桌面端)/park(园区端)/operator(运营端),逗号分隔多选,all=全部 + soul: Mapped[str] = mapped_column(Text, default="{}") # JSON {"user": 用户可编辑段, "server": 服务端隐藏注入段} + profile: Mapped[str] = mapped_column(Text, default="{}") # 同上(PROFILE.md) + heartbeat: Mapped[str] = mapped_column(Text, default="{}") # 同上(HEARTBEAT.md) + memory: Mapped[str] = mapped_column(Text, default="{}") # 同上(MEMORY.md) + enabled: Mapped[bool] = mapped_column(Boolean, default=True) # 是否下发(登录同步时启用才初始化) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + # ── 运营端业务模型(平台运营/任务/服务商/内容/配置)──────────────────────── class Task(Base): @@ -1539,3 +1567,206 @@ class IncubatorSetting(Base): open_summary: Mapped[str] = mapped_column(Text, default="") # 开放区域总览说明 updated_by: Mapped[str] = mapped_column(String, default="") updated_at: Mapped[str] = mapped_column(String, default="") + + +# =========================================================================== +# 信用认证体系(个人认证 / 企业认证 / OPC人才认证 / 技能认证) +# =========================================================================== + +class CertificationType(Base): + """认证类型配置表(运营可配置扩展)。""" + __tablename__ = "certification_types" + + id: Mapped[str] = mapped_column(String, primary_key=True) + code: Mapped[str] = mapped_column(String, unique=True, index=True, default="") + category: Mapped[str] = mapped_column(String, default="personal") # personal/enterprise/opc_talent/skill + name: Mapped[str] = mapped_column(String, default="") + description: Mapped[str] = mapped_column(Text, default="") + fee: Mapped[int] = mapped_column(Integer, default=0) # 分,0=免费 + validity_days: Mapped[int] = mapped_column(Integer, default=0) # 0=永久 + credit_points: Mapped[int] = mapped_column(Integer, default=0) + levels_json: Mapped[str] = mapped_column(Text, default="[]") # 等级配置JSON + materials_schema: Mapped[str] = mapped_column(Text, default="{}") # 表单JSON Schema + review_mode: Mapped[str] = mapped_column(String, default="manual") # auto/manual/hybrid + api_provider: Mapped[str] = mapped_column(String, default="") + badge_icon: Mapped[str] = mapped_column(String, default="") + badge_color: Mapped[str] = mapped_column(String, default="") + sort_order: Mapped[int] = mapped_column(Integer, default=0) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class Certification(Base): + """认证申请表(统一所有认证类型,替代旧 opc_certifications)。""" + __tablename__ = "certifications" + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str] = mapped_column(String, default="", index=True) + enterprise_id: Mapped[str] = mapped_column(String, default="", index=True) + cert_type: Mapped[str] = mapped_column(String, default="", index=True) # 类型编码 + cert_category: Mapped[str] = mapped_column(String, default="personal") # 大类 + level: Mapped[str] = mapped_column(String, default="") # 等级 + status: Mapped[str] = mapped_column(String, default="not_started", index=True) + # not_started/pending/approved/rejected/active/expiring/expired/revoked + materials_json: Mapped[str] = mapped_column(Text, default="{}") # 提交材料(脱敏) + score: Mapped[int] = mapped_column(Integer, default=0) # 认证分数(技能测试用) + credit_points: Mapped[int] = mapped_column(Integer, default=0) # 获得的信用加分 + fee_amount: Mapped[int] = mapped_column(Integer, default=0) # 费用(分) + payment_id: Mapped[str] = mapped_column(String, default="") + submitted_at: Mapped[str] = mapped_column(String, default="") + approved_at: Mapped[str] = mapped_column(String, default="") + expires_at: Mapped[str] = mapped_column(String, default="") + reviewer_id: Mapped[str] = mapped_column(String, default="") + review_note: Mapped[str] = mapped_column(Text, default="") + revoke_reason: Mapped[str] = mapped_column(Text, default="") + revoked_at: Mapped[str] = mapped_column(String, default="") + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class SkillTest(Base): + """技能测试记录表。""" + __tablename__ = "skill_tests" + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str] = mapped_column(String, default="", index=True) + skill_domain: Mapped[str] = mapped_column(String, default="", index=True) + status: Mapped[str] = mapped_column(String, default="paid") + # paid/in_progress/submitted/scored/completed/expired + score: Mapped[int] = mapped_column(Integer, default=0) + level: Mapped[str] = mapped_column(String, default="") + objective_score: Mapped[int] = mapped_column(Integer, default=0) + subjective_score: Mapped[int] = mapped_column(Integer, default=0) + answers_json: Mapped[str] = mapped_column(Text, default="{}") + cheating_flags: Mapped[int] = mapped_column(Integer, default=0) + started_at: Mapped[str] = mapped_column(String, default="") + submitted_at: Mapped[str] = mapped_column(String, default="") + scored_at: Mapped[str] = mapped_column(String, default="") + certification_id: Mapped[str] = mapped_column(String, default="", index=True) + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +# ═══════════════════════════════════════════════════════════════ +# 培训与技能认证体系 +# ═══════════════════════════════════════════════════════════════ + +class TrainingCourse(Base): + """课程表。""" + __tablename__ = "training_courses" + + id: Mapped[str] = mapped_column(String, primary_key=True) + title: Mapped[str] = mapped_column(String, default="") + subtitle: Mapped[str] = mapped_column(String, default="") + description: Mapped[str] = mapped_column(Text, default="") + cover_url: Mapped[str] = mapped_column(String, default="") + category: Mapped[str] = mapped_column(String, default="", index=True) + # ui_design/graphic_design/frontend_dev/backend_dev/copywriting/translation/data_analysis/project_management + level: Mapped[str] = mapped_column(String, default="beginner") + # beginner/intermediate/advanced/expert + price: Mapped[int] = mapped_column(Integer, default=0) # 分,0=免费 + duration_minutes: Mapped[int] = mapped_column(Integer, default=0) + lesson_count: Mapped[int] = mapped_column(Integer, default=0) + chapter_count: Mapped[int] = mapped_column(Integer, default=0) + instructor_id: Mapped[str] = mapped_column(String, default="") + instructor_name: Mapped[str] = mapped_column(String, default="") + instructor_title: Mapped[str] = mapped_column(String, default="") + instructor_avatar: Mapped[str] = mapped_column(String, default="") + tags_json: Mapped[str] = mapped_column(Text, default="[]") + prerequisites: Mapped[str] = mapped_column(Text, default="") + learning_outcomes: Mapped[str] = mapped_column(Text, default="") + status: Mapped[str] = mapped_column(String, default="draft") # draft/published/archived + is_featured: Mapped[int] = mapped_column(Integer, default=0) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + enroll_count: Mapped[int] = mapped_column(Integer, default=0) + rating: Mapped[float] = mapped_column(Float, default=0.0) + rating_count: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class TrainingChapter(Base): + """课程章节表。""" + __tablename__ = "training_chapters" + + id: Mapped[str] = mapped_column(String, primary_key=True) + course_id: Mapped[str] = mapped_column(String, default="", index=True) + title: Mapped[str] = mapped_column(String, default="") + description: Mapped[str] = mapped_column(String, default="") + sort_order: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[str] = mapped_column(String, default="") + + +class TrainingLesson(Base): + """课程课时表。""" + __tablename__ = "training_lessons" + + id: Mapped[str] = mapped_column(String, primary_key=True) + chapter_id: Mapped[str] = mapped_column(String, default="", index=True) + course_id: Mapped[str] = mapped_column(String, default="", index=True) + title: Mapped[str] = mapped_column(String, default="") + type: Mapped[str] = mapped_column(String, default="video") # video/article/quiz/exam + content: Mapped[str] = mapped_column(Text, default="") + duration_minutes: Mapped[int] = mapped_column(Integer, default=0) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + is_free: Mapped[int] = mapped_column(Integer, default=0) + is_exam: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[str] = mapped_column(String, default="") + + +class TrainingCourseEnrollment(Base): + """课程报名记录表。""" + __tablename__ = "training_course_enrollments" + + id: Mapped[str] = mapped_column(String, primary_key=True) + user_id: Mapped[str] = mapped_column(String, default="", index=True) + course_id: Mapped[str] = mapped_column(String, default="", index=True) + status: Mapped[str] = mapped_column(String, default="enrolled") + # enrolled/in_progress/completed/dropped + progress_percent: Mapped[int] = mapped_column(Integer, default=0) + completed_lessons: Mapped[int] = mapped_column(Integer, default=0) + total_lessons: Mapped[int] = mapped_column(Integer, default=0) + last_lesson_id: Mapped[str] = mapped_column(String, default="") + last_studied_at: Mapped[str] = mapped_column(String, default="") + total_watch_seconds: Mapped[int] = mapped_column(Integer, default=0) + enrolled_at: Mapped[str] = mapped_column(String, default="") + completed_at: Mapped[str] = mapped_column(String, default="") + price_paid: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[str] = mapped_column(String, default="") + + +class TrainingLessonProgress(Base): + """课时学习进度表。""" + __tablename__ = "training_lesson_progress" + + id: Mapped[str] = mapped_column(String, primary_key=True) + enrollment_id: Mapped[str] = mapped_column(String, default="", index=True) + user_id: Mapped[str] = mapped_column(String, default="", index=True) + lesson_id: Mapped[str] = mapped_column(String, default="", index=True) + course_id: Mapped[str] = mapped_column(String, default="", index=True) + status: Mapped[str] = mapped_column(String, default="not_started") + # not_started/in_progress/completed + watch_seconds: Mapped[int] = mapped_column(Integer, default=0) + quiz_score: Mapped[int] = mapped_column(Integer, default=0) + started_at: Mapped[str] = mapped_column(String, default="") + completed_at: Mapped[str] = mapped_column(String, default="") + updated_at: Mapped[str] = mapped_column(String, default="") + + +class TrainingCertificate(Base): + """结业证书表。""" + __tablename__ = "training_certificates" + + id: Mapped[str] = mapped_column(String, primary_key=True) + certificate_no: Mapped[str] = mapped_column(String, default="", index=True) + user_id: Mapped[str] = mapped_column(String, default="", index=True) + course_id: Mapped[str] = mapped_column(String, default="", index=True) + course_title: Mapped[str] = mapped_column(String, default="") + instructor_name: Mapped[str] = mapped_column(String, default="") + duration_minutes: Mapped[int] = mapped_column(Integer, default=0) + final_score: Mapped[int] = mapped_column(Integer, default=0) + issued_at: Mapped[str] = mapped_column(String, default="") + badge_id: Mapped[str] = mapped_column(String, default="") + is_verified: Mapped[int] = mapped_column(Integer, default=1) + created_at: Mapped[str] = mapped_column(String, default="") diff --git a/app/infrastructure/seed.py b/app/infrastructure/seed.py index 2e04a74..59f6cee 100644 --- a/app/infrastructure/seed.py +++ b/app/infrastructure/seed.py @@ -41,9 +41,8 @@ from .models import ( TaskCategory, TrainingEnrollment, User, - Agent, ) -from .repositories import AGENT_SEED, new_id, utcnow_iso +from .repositories import new_id, utcnow_iso from .security import hash_password # --------------------------------------------------------------------------- @@ -111,6 +110,7 @@ PERMISSIONS = [ ("menu:admin_providers", "服务商管理", "menu", "admin"), ("menu:admin_market", "市场管理", "menu", "admin"), ("menu:admin_config", "系统配置", "menu", "admin"), + ("menu:admin_agents", "智能体管理", "menu", "admin"), ("menu:admin_park", "平台园区", "menu", "admin"), ("menu:admin_park_screens", "屏幕绑定", "menu", "admin"), ("menu:opc_cert", "OPC认证", "menu", "admin"), @@ -148,6 +148,7 @@ ROLE_PERMISSIONS: dict[str, list[str]] = { "menu:admin_courses", "menu:admin_activities", "menu:admin_bookings", "menu:admin_tests", "menu:admin_surveys", "menu:admin_policies", "menu:admin_plans", "menu:admin_compute", "menu:admin_content", "menu:admin_content_review", "menu:admin_tasks", "menu:admin_providers", "menu:admin_config", + "menu:admin_agents", "menu:admin_market", "menu:admin_park", "menu:admin_park_screens", "menu:opc_cert", "menu:admission", "menu:park_transfer", "action:user.assign_role", "action:user.disable", "action:role.grant_perm", @@ -226,26 +227,6 @@ DEMO_USERS = [ ] -async def _add_agent(session: AsyncSession, seed: dict, user_id: str, port: str | None, now: str) -> None: - """幂等播种单个智能体:方言 INSERT 冲突跳过(SQLite=INSERT OR IGNORE, - MySQL=INSERT IGNORE),杜绝 UNIQUE 抛错。""" - from sqlalchemy.dialects.mysql import insert as mysql_insert - from sqlalchemy.dialects.sqlite import insert as sqlite_insert - port_val = port or "" # agents.port NOT NULL;无端口身份用 "" - values = dict( - id=seed["id"], user_id=user_id, port=port_val, name=seed["name"], - description=seed["description"], language=seed.get("language", "zh"), - model_name=seed.get("model_name", ""), deletable=seed.get("deletable", True), - use_fixed_soul=seed.get("use_fixed_soul", False), created_at=now, updated_at=now, - ) - if session.get_bind().dialect.name == "mysql": - stmt = mysql_insert(Agent).values(**values).prefix_with("IGNORE") - else: - stmt = sqlite_insert(Agent).values(**values).on_conflict_do_nothing( - index_elements=["id", "user_id", "port"]) - await session.execute(stmt) - - # 系统配置默认值(平台费率 + 短信生态)。幂等补种:仅插入缺失键,不覆盖已有值。 _DEFAULT_SYSTEM_CONFIGS: list[tuple[str, str, str]] = [ ("platform.fee.task", "0.05", "任务佣金费率(5%)"), @@ -339,9 +320,6 @@ async def seed_data(session: AsyncSession) -> None: source="seed", auth_type="admin", ) ) - # 为每个用户播种默认 + QA 智能体(幂等按 (id,user_id,port=null) 防重) - for seed in AGENT_SEED: - await _add_agent(session, seed, uid, None, now) await _seed_task_categories(session, now) await _seed_operator_business(session, now) @@ -907,8 +885,6 @@ async def _ensure_extra_demo_users(session: AsyncSession, now: str) -> None: source="seed", auth_type="admin", ) ) - for seed in AGENT_SEED: - await _add_agent(session, seed, uid, None, now) async def _seed_ecosystem(session: AsyncSession, now: str) -> None: diff --git a/app/pay/config.py b/app/pay/config.py index b6ef464..ab6d26d 100644 --- a/app/pay/config.py +++ b/app/pay/config.py @@ -69,11 +69,13 @@ def pay_enabled() -> bool: # --------------------------------------------------------------------------- -# 微信支付服务商(分账 / 资金托管)配置 —— 与直连商户配置并存,分账以其为开关 +# 微信支付服务商(分账 / 资金托管 / 特约商户进件)配置 —— 与直连商户配置并存 # --------------------------------------------------------------------------- -# 服务商商户号(10 位):分账调用 /v3/profitsharing/orders 的请求主体(sp_mchid) +# 服务商商户号(10 位):分账调用 /v3/profitsharing/orders 的请求主体(sp_mchid), +# 以及特约商户进件(applyment4sub)的发起方。在服务商商户平台 pay.weixin.qq.com/partner 查看。 WECHATPAY_SP_MCHID = os.environ.get("PINEAGENTS_WX_SP_MCHID", "") -# 服务商 AppID(小程序/公众号):PERSONAL_OPENID 接收方的 openid 归属该 appid +# 服务商 AppID(小程序/公众号):PERSONAL_OPENID 接收方的 openid 归属该 appid; +# 在微信公众平台 mp.weixin.qq.com 查看,需与服务商商户号完成绑定授权。 WECHATPAY_SP_APPID = os.environ.get("PINEAGENTS_WX_SP_APPID", "") # 分账结果回调通知地址(公网 HTTPS,如 https://opc.pinesound.cn/opc/pay/profitsharing/notify) WECHATPAY_SPLIT_NOTIFY_URL = os.environ.get("PINEAGENTS_WX_SPLIT_NOTIFY_URL", "") @@ -82,10 +84,33 @@ WECHATPAY_SPLIT_COMMISSION_RATE = float( os.environ.get("PINEAGENTS_WX_SPLIT_COMMISSION_RATE", "0.05") ) +# ── 服务商独立 API 证书(若服务商商户号 ≠ 直连商户号则必须独立配置,否则签名失败)── +# 服务商商户号是独立商户号时,其 API 证书序列号 / 私钥 / APIv3 密钥 / 平台证书均与直连商户不同, +# 必须从服务商商户平台【账户中心 > API 安全】下载并单独配置;未配置时回退使用直连商户证书(仅适用于 +# 服务商与直连为同一商户号的场景)。 +WECHATPAY_SP_PRIVATE_KEY_PATH = os.environ.get( + "PINEAGENTS_WX_SP_PRIVATE_KEY_PATH", WECHATPAY_PRIVATE_KEY_PATH, +) +WECHATPAY_SP_CERT_SERIAL_NO = os.environ.get( + "PINEAGENTS_WX_SP_CERT_SERIAL_NO", WECHATPAY_CERT_SERIAL_NO, +) +WECHATPAY_SP_APIV3_KEY = os.environ.get( + "PINEAGENTS_WX_SP_APIV3_KEY", WECHATPAY_APIV3_KEY, +) +WECHATPAY_SP_CERT_DIR = os.environ.get( + "PINEAGENTS_WX_SP_CERT_DIR", WECHATPAY_CERT_DIR, +) +WECHATPAY_SP_PUBLIC_KEY_PATH = os.environ.get( + "PINEAGENTS_WX_SP_PUBLIC_KEY_PATH", WECHATPAY_PUBLIC_KEY_PATH, +) +WECHATPAY_SP_PUBLIC_KEY_ID = os.environ.get( + "PINEAGENTS_WX_SP_PUBLIC_KEY_ID", WECHATPAY_PUBLIC_KEY_ID, +) + def profitsharing_enabled() -> bool: - """服务商分账能力是否可用(sp_mchid + 证书/密钥 + 回调地址齐备)。""" + """服务商分账能力是否可用(sp_mchid + 服务商证书/密钥 + 回调地址齐备)。""" return bool( - WECHATPAY_SP_MCHID and WECHATPAY_CERT_SERIAL_NO and WECHATPAY_APIV3_KEY - and WECHATPAY_PRIVATE_KEY_PATH and WECHATPAY_SPLIT_NOTIFY_URL + WECHATPAY_SP_MCHID and WECHATPAY_SP_CERT_SERIAL_NO and WECHATPAY_SP_APIV3_KEY + and WECHATPAY_SP_PRIVATE_KEY_PATH and WECHATPAY_SPLIT_NOTIFY_URL ) diff --git a/app/pay/models.py b/app/pay/models.py index 5eac373..cffbc77 100644 --- a/app/pay/models.py +++ b/app/pay/models.py @@ -92,6 +92,14 @@ class PaymentBinding(Base): applyment_id: Mapped[str] = mapped_column(String, default="") # merchant:进件申请单号 status: Mapped[str] = mapped_column(String, default="applying", index=True) # applying|active|disabled|rejected detail: Mapped[str] = mapped_column(String, default="") # 进件驳回原因/接收方状态提示 + # ── 进件生命周期(applyment4sub 查询回填,前端据此展示签约/验证引导)── + applyment_state: Mapped[str] = mapped_column(String, default="") # 微信 applyment_state 枚举 + sign_url: Mapped[str] = mapped_column(String, default="") # 超管签约链接(扫码完成验证+授权) + account_validation_json: Mapped[str] = mapped_column(Text, default="{}") # 账户验证信息(打款/法人) + audit_detail_json: Mapped[str] = mapped_column(Text, default="{}") # 驳回原因/状态说明 + # ── 分账授权(查询 /v3/profitsharing/merchant-configs 回填)── + split_allowed: Mapped[str] = mapped_column(String, default="") # OPEN|CLOSED|WAIT_CONFIRM + split_max_ratio: Mapped[int] = mapped_column(Integer, default=0) # 允许服务商分账的最大比例(%) created_at: Mapped[str] = mapped_column(String, default="", index=True) updated_at: Mapped[str] = mapped_column(String, default="") diff --git a/app/pay/profitsharing.py b/app/pay/profitsharing.py index 7ee30b6..5379159 100644 --- a/app/pay/profitsharing.py +++ b/app/pay/profitsharing.py @@ -23,6 +23,20 @@ from wechatpayv3.async_.utils import aes_decrypt from . import config as pay_config +# 进件申请状态枚举(微信 applyment_state) +APPLYMENT_STATE = { + "EDITTING": "资料编辑中", + "AUDITING": "微信审核中", + "REJECTED": "已驳回", + "TO_BE_CONFIRMED": "待账户验证(需超管扫码完成打款/法人验证)", + "TO_BE_SIGNED": "待签约(需超管扫码完成绑定+签约授权)", + "SIGNING": "开通权限中(签约后平台开通分账等产品权限)", + "FINISHED": "已完成(子商户号已下发)", + "CANCELED": "已作废", +} +# 待平台侧人工/超管介入的状态(前端应展示 sign_url 二维码引导超管扫码) +APPLYMENT_ACTION_STATES = ("TO_BE_CONFIRMED", "TO_BE_SIGNED", "SIGNING") + logger = logging.getLogger("pay.profitsharing") _sp: Optional[AsyncWeChatPay] = None @@ -47,20 +61,21 @@ async def _ensure_split_pay() -> bool: return False _init_lock = True try: - with open(pay_config.WECHATPAY_PRIVATE_KEY_PATH, mode="r") as f: + with open(pay_config.WECHATPAY_SP_PRIVATE_KEY_PATH, mode="r") as f: private_key = f.read() import os - public_key_path = pay_config.WECHATPAY_PUBLIC_KEY_PATH - # 服务商模式:mchid=服务商商户号,appid=服务商 AppID;证书/密钥与直连同商户侧体系 + public_key_path = pay_config.WECHATPAY_SP_PUBLIC_KEY_PATH + # 服务商模式:mchid=服务商商户号,appid=服务商 AppID;证书/密钥用服务商独立配置 + # (若未单独配置则回退直连证书,仅适用于服务商与直连为同一商户号的场景) _sp = AsyncWeChatPay( wechatpay_type=WeChatPayType.NATIVE, mchid=pay_config.WECHATPAY_SP_MCHID, private_key=private_key, - cert_serial_no=pay_config.WECHATPAY_CERT_SERIAL_NO, + cert_serial_no=pay_config.WECHATPAY_SP_CERT_SERIAL_NO, appid=pay_config.WECHATPAY_SP_APPID or pay_config.WECHATPAY_NATIVE_APPID, - apiv3_key=pay_config.WECHATPAY_APIV3_KEY, + apiv3_key=pay_config.WECHATPAY_SP_APIV3_KEY, notify_url=pay_config.WECHATPAY_SPLIT_NOTIFY_URL, - cert_dir=pay_config.WECHATPAY_CERT_DIR, + cert_dir=pay_config.WECHATPAY_SP_CERT_DIR, logger=logger, partner_mode=True, public_key=( @@ -68,7 +83,7 @@ async def _ensure_split_pay() -> bool: if public_key_path and os.path.exists(public_key_path) else None ), - public_key_id=pay_config.WECHATPAY_PUBLIC_KEY_ID or None, + public_key_id=pay_config.WECHATPAY_SP_PUBLIC_KEY_ID or None, ) await _sp.__aenter__() logger.info("微信服务商分账客户端懒加载初始化成功 sp_mchid=%s", pay_config.WECHATPAY_SP_MCHID) @@ -97,12 +112,14 @@ def _parse(data, code: int, result) -> dict: # 分账接收方 # --------------------------------------------------------------------------- async def add_receiver(*, account_type: str, account: str, name: str = "", - relation_type: str = "SERVICE_PROVIDER") -> dict: + relation_type: str = "SERVICE_PROVIDER", + sub_mchid: str = "") -> dict: """添加分账接收方。 - - account_type: MERCHANT_ID(子商户号) | PERSONAL_OPENID(个人微信零钱) + - account_type: MERCHANT_ID(商户号) | PERSONAL_OPENID(个人微信零钱) - account: 子商户号 或 小程序 openid - name: 个人实名(PERSONAL_OPENID 时传,微信校验实名一致) + - sub_mchid: 【服务商模式必填】分账的出资特约商户号(接收方被添加到该出资方名下)。 """ if not await _ensure_split_pay(): raise RuntimeError(f"服务商分账未就绪: {_init_error or '未配置'}") @@ -111,6 +128,7 @@ async def add_receiver(*, account_type: str, account: str, name: str = "", relation_type=relation_type, name=name or None, appid=pay_config.WECHATPAY_SP_APPID or pay_config.WECHATPAY_NATIVE_APPID, + sub_mchid=sub_mchid or None, ) return _parse(result, code, result) @@ -127,6 +145,7 @@ async def create_split(*, transaction_id: str, out_order_no: str, - out_order_no: 平台分账单号(幂等键,PS__) - receivers: [{type, account, amount(分), description}] - sub_mchid: 服务商模式下收单子商户号(分账方) + - unfreeze_unsplit: True=分账同时解冻剩余;False=部分分账,后续可再分账/单独解冻 """ if not await _ensure_split_pay(): raise RuntimeError(f"服务商分账未就绪: {_init_error or '未配置'}") @@ -140,6 +159,161 @@ async def create_split(*, transaction_id: str, out_order_no: str, return _parse(result, code, result) +# --------------------------------------------------------------------------- +# 分账生命周期补充:剩余待分金额 / 分账比例 / 完结分账解冻剩余 +# --------------------------------------------------------------------------- +async def query_remaining_amount(*, transaction_id: str) -> dict | None: + """查询订单剩余待分金额(分)。支付成功且订单标记 profit_sharing 后可用。 + + 返回 {"transaction_id": ..., "unsplit_amount": int(分), "split_fee": int(分), ...}。 + 用于"资金冻结确认":unsplit_amount 即该订单当前可发起分账的冻结资金。 + """ + if not await _ensure_split_pay(): + return None + try: + code, result = await _sp.profitsharing_amount_query(transaction_id=transaction_id) + except Exception as exc: # noqa: BLE001 + logger.warning("查询剩余待分金额异常 tx=%s: %s", transaction_id, exc) + return None + if code != 200: + return None + return result if isinstance(result, dict) else json.loads(result) + + +async def query_split_config(*, sub_mchid: str) -> dict | None: + """查询子商户分账配置(允许服务商分账的最大比例)。 + + 对应微信接口:GET /v3/profitsharing/merchant-configs/{sub_mchid}。 + 返回 {"sub_mchid": ..., "max_ratio": int(万分比)}: + - max_ratio 单位为万分比(2000 = 20%),为子商户在商户平台设置的允许服务商分账的最大比例, + 未授权/未开通时微信返回 403 NO_AUTH(此处记 None,由调用方提示需子商户在商户平台开通分账)。 + 注意:该接口仅返回 max_ratio,不返回分账开关;分账是否可用以能否成功 add_receiver/查询比例为准。 + """ + if not await _ensure_split_pay(): + return None + try: + code, result = await _sp.profitsharing_config_query(sub_mchid=sub_mchid) + except Exception as exc: # noqa: BLE001 + logger.warning("查询分账配置异常 sub_mchid=%s: %s", sub_mchid, exc) + return None + if code != 200: + return None + data = result if isinstance(result, dict) else json.loads(result) + if isinstance(data, dict) and "max_ratio" in data: + # 万分比 → 百分比(保留一位小数),便于前端展示 + data["max_ratio_percent"] = round(int(data["max_ratio"]) / 100.0, 1) + return data + + +async def unfreeze_remaining(*, transaction_id: str, out_order_no: str, + sub_mchid: str = "", description: str = "解冻剩余资金") -> dict: + """完结分账,解冻订单剩余未分资金回出资方商户。 + + 对应微信接口:POST /v3/profitsharing/orders/unfreeze。 + - out_order_no: 分账完结单号,**不能与请求分账时的 out_order_no 相同**(需新生成); + - 解冻后该订单不能再发起分账。 + """ + if not await _ensure_split_pay(): + raise RuntimeError(f"服务商分账未就绪: {_init_error or '未配置'}") + code, result = await _sp.profitsharing_unfreeze( + transaction_id=transaction_id, out_order_no=out_order_no, + description=description, sub_mchid=sub_mchid or None, + ) + return _parse(result, code, result) + + +def split_finished(result: dict) -> tuple[bool, str]: + """判断分账结果是否全部成功:state=FINISHED 且所有 receivers.result=SUCCESS。 + + 微信分账为异步:state=FINISHED 仅代表动账执行完毕,须逐接收方看 result。 + 返回 (全部成功?, 失败原因摘要)。 + """ + if not isinstance(result, dict): + return False, "分账结果为空" + if result.get("state") == "PROCESSING": + return False, "分账处理中,请稍后查询" + if result.get("state") != "FINISHED": + return False, f"分账未完成 state={result.get('state')}" + receivers = result.get("receivers") or [] + if not receivers: + return False, "分账结果缺少接收方明细" + failed = [r.get("fail_reason") or f"result={r.get('result')}" for r in receivers + if r.get("result") != "SUCCESS"] + return (not failed), (";".join(failed) if failed else "") + + +# --------------------------------------------------------------------------- +# 服务商代子商户收单(partner transactions + profit_sharing 标记) +# --------------------------------------------------------------------------- +async def create_partner_order(*, client_type: str, out_trade_no: str, total_fen: int, + description: str, sub_mchid: str, + payer_sp_openid: str = "", + notify_url: str = "", profit_sharing: bool = True) -> dict: + """服务商代子商户收单(资金直接进子商户账户并可分账)。 + + 对应微信接口(partner transactions 系列): + - native: POST /v3/pay/partner/transactions/native → {code_url} + - jsapi : POST /v3/pay/partner/transactions/jsapi → {pay_params}(需 payer_sp_openid) + 关键:profit_sharing=True 时订单带分账标记,支付成功后资金在子商户账户冻结,可分账。 + """ + if not await _ensure_split_pay(): + raise RuntimeError(f"服务商分账未就绪: {_init_error or '未配置'}") + if client_type == "jsapi" and not payer_sp_openid: + raise RuntimeError("JSAPI 收单缺少用户 openid(payer_sp_openid)") + notify = notify_url or pay_config.WECHATPAY_SPLIT_NOTIFY_URL + appid = pay_config.WECHATPAY_SP_APPID or pay_config.WECHATPAY_NATIVE_APPID + pay_type = WeChatPayType.MINIPROG if client_type == "jsapi" else WeChatPayType.NATIVE + payer = {"sp_openid": payer_sp_openid} if client_type == "jsapi" else None + settle_info = {"profit_sharing": True} if profit_sharing else None + try: + code, result = await _sp.pay( + description=description, out_trade_no=out_trade_no, + amount={"total": int(total_fen), "currency": "CNY"}, + payer=payer, pay_type=pay_type, appid=appid, + sub_mchid=sub_mchid, settle_info=settle_info, + notify_url=notify, + ) + except Exception as exc: # noqa: BLE001 + raise RuntimeError(f"微信服务商收单失败: {str(exc)[:300]}") from exc + if code != 200: + raise RuntimeError(f"微信服务商收单失败 http={code}: {str(result)[:300]}") + data = result if isinstance(result, dict) else json.loads(result) + if client_type == "jsapi": + prepay_id = data.get("prepay_id") or "" + if not prepay_id: + raise RuntimeError(f"微信服务商收单无 prepay_id: {str(data)[:300]}") + time_stamp = str(int(__import__("time").time())) + import uuid + nonce_str = uuid.uuid4().hex + package = f"prepay_id={prepay_id}" + # 服务商模式:签名主体为服务商商户私钥 + 服务商 AppID + pay_sign = _sp.sign([appid, time_stamp, nonce_str, package]) + return {"appid": appid, "prepay_id": prepay_id, + "pay_params": {"timeStamp": time_stamp, "nonceStr": nonce_str, + "package": package, "signType": "RSA", "paySign": pay_sign}} + code_url = data.get("code_url") or "" + if not code_url: + raise RuntimeError(f"微信服务商收单无 code_url: {str(data)[:300]}") + return {"appid": appid, "code_url": code_url} + + +# --------------------------------------------------------------------------- +# 结算账户查询/修改(子商户进件后对公/法人结算账户管理) +# --------------------------------------------------------------------------- +async def query_settlement(*, sub_mchid: str) -> dict | None: + """查询子商户结算账户(开户行/账号脱敏/结算类型等)。""" + if not await _ensure_split_pay(): + return None + try: + code, result = await _sp.applyment_settlement_query(sub_mchid=sub_mchid) + except Exception as exc: # noqa: BLE001 + logger.warning("查询结算账户异常 sub_mchid=%s: %s", sub_mchid, exc) + return None + if code != 200: + return None + return result if isinstance(result, dict) else json.loads(result) + + async def query_split(*, transaction_id: str, out_order_no: str, sub_mchid: str = "") -> Optional[dict]: """查询分账结果;无结果返回 None。""" @@ -198,11 +372,32 @@ async def submit_applyment(*, business_code: str, contact_info: dict, return _parse(result, code, result) +def normalize_applyment_state(state: str) -> str: + """把微信 applyment_state 规范化为裸枚举(去掉 APPLYMENT_STATE_ 前缀)。 + + 官方查询接口返回形如 ``APPLYMENT_STATE_FINISHED``;SDK 原样透传不剥前缀。 + 统一转成裸枚举(FINISHED / TO_BE_SIGNED / ...),供业务层与前端一致使用。 + 兼容已剥前缀的返回值。 + """ + if not state: + return state + if state.startswith("APPLYMENT_STATE_"): + return state[len("APPLYMENT_STATE_"):] + return state + + async def query_applyment(*, business_code: str = "", applyment_id: str = "") -> dict | None: """查询特约商户进件申请状态;失败返回 None。 - applyment_state 枚举:EDITTING / AUDITING / REJECTED / FINISHED / CANCELED; - FINISHED 后返回 sub_mchid(特约商户号)与 account_validation(打款验证信息)。 + applyment_state 枚举(见模块头 APPLYMENT_STATE,已规范化去掉前缀): + EDITTING / AUDITING / REJECTED / TO_BE_CONFIRMED(待账户验证) / + TO_BE_SIGNED(待签约) / SIGNING(开通权限中) / FINISHED / CANCELED。 + 关键返回字段: + - sign_url:超级管理员签约链接(查询即返回),超管用微信扫码→关注"微信支付商家助手" + →根据公众号指引完成【核对联系信息 + 账户验证(打款/法人) + 签约授权】; + - account_validation:账户验证信息(打款验证收款账户、金额区间、验证截止时间); + - sub_mchid:TO_BE_SIGNED / SIGNING / FINISHED 时返回(特约商户号); + - audit_detail:REJECTED 时返回 [{field, field_name, reject_reason}](驳回原因详情)。 """ if not await _ensure_split_pay(): return None @@ -218,7 +413,10 @@ async def query_applyment(*, business_code: str = "", applyment_id: str = "") -> return None if code != 200: return None - return result if isinstance(result, dict) else json.loads(result) + data = result if isinstance(result, dict) else json.loads(result) + if isinstance(data, dict) and data.get("applyment_state"): + data["applyment_state"] = normalize_applyment_state(data["applyment_state"]) + return data async def upload_image(*, filepath: str, filename: str = "") -> dict: diff --git a/app/pay/repository.py b/app/pay/repository.py index 23f1619..254573c 100644 --- a/app/pay/repository.py +++ b/app/pay/repository.py @@ -220,6 +220,10 @@ def _to_binding_dict(b: PaymentBinding) -> dict: "id": b.id, "user_id": b.user_id, "bind_type": b.bind_type, "openid": b.openid, "real_name": b.real_name, "sub_mchid": b.sub_mchid, "applyment_id": b.applyment_id, "status": b.status, "detail": b.detail or "", + "applyment_state": b.applyment_state or "", "sign_url": b.sign_url or "", + "account_validation_json": b.account_validation_json or "{}", + "audit_detail_json": b.audit_detail_json or "{}", + "split_allowed": b.split_allowed or "", "split_max_ratio": b.split_max_ratio or 0, "created_at": b.created_at, "updated_at": b.updated_at, } @@ -251,6 +255,19 @@ class PaymentBindingRepository: )).all() return [_to_binding_dict(r) for r in rows] + async def list_all(self, bind_type: str | None = None, + status: str | None = None, + limit: int = 200) -> list[dict]: + """管理端:列出全部 OPC 收款绑定(可筛 bind_type/status),按更新时间倒序。""" + stmt = select(PaymentBinding) + if bind_type: + stmt = stmt.where(PaymentBinding.bind_type == bind_type) + if status: + stmt = stmt.where(PaymentBinding.status == status) + stmt = stmt.order_by(PaymentBinding.updated_at.desc()).limit(limit) + rows = (await self.session.scalars(stmt)).all() + return [_to_binding_dict(r) for r in rows] + async def get_active(self, user_id: str, bind_type: str | None = None) -> dict | None: """取该用户可接收分账的绑定(优先商户绑定→个人绑定;bind_type 指定时仅取该类型)。""" order = ("merchant", "personal") if bind_type is None else (bind_type,) diff --git a/app/pay/routers.py b/app/pay/routers.py index 7906b90..7518261 100644 --- a/app/pay/routers.py +++ b/app/pay/routers.py @@ -351,3 +351,47 @@ async def opc_query_applyment( raise HTTPException(status_code=404, detail=str(exc)) from exc except RuntimeError as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc + + +@router.get("/opc/pay/bindings/merchant/{binding_id}/split-config", summary="查询子商户分账授权/最大比例") +async def opc_split_config( + binding_id: str, + db: Database = Depends(get_db), + user: dict = Depends(require_roles("opc_member")), +): + """查询微信侧子商户允许服务商分账的最大比例(需子商户在商户平台开通分账并授权)。""" + try: + return await service.query_split_permission(db, user, binding_id) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + +class PartnerOrderRequest(BaseModel): + """服务商代子商户收单(桌面端 OPC 开户认证后,任务托管款/企业付款下单)。""" + sub_mchid: str = Field(description="出资特约商户号(接单者 OPC 的特约商户号)") + out_trade_no: str = Field(description="平台业务订单号(幂等键)") + amount_fen: int = Field(gt=0, description="金额(分)") + description: str = Field(default="云超服任务托管款", description="订单描述") + client_type: str = Field(default="native", description="native(桌面扫码) | jsapi(小程序)") + payer_openid: str = Field(default="", description="jsapi 时:用户 openid(服务商 AppID 下)") + + +@router.post("/opc/pay/bindings/merchant/partner-order", summary="服务商代子商户收单(profit_sharing 冻结)") +async def opc_partner_order( + body: PartnerOrderRequest, + db: Database = Depends(get_db), + user: dict = Depends(require_roles("opc_member")), +): + """服务商模式下单到接单者特约商户(资金进子商户不可用余额冻结), + 返回 Native code_url 或 JSAPI pay_params。支付成功回调(/opc/pay/notify 或分账回调)后走分账。""" + try: + return await profitsharing.create_partner_order( + client_type=body.client_type, out_trade_no=body.out_trade_no, + total_fen=body.amount_fen, description=body.description, + sub_mchid=body.sub_mchid, payer_sp_openid=body.payer_openid, + profit_sharing=True, + ) + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc diff --git a/app/pay/service.py b/app/pay/service.py index 2233a3c..ba5a94c 100644 --- a/app/pay/service.py +++ b/app/pay/service.py @@ -363,9 +363,14 @@ async def bind_personal(db: Database, user: dict, real_name: str = "") -> dict: if not pay_config.profitsharing_enabled(): raise RuntimeError("服务商分账未配置,暂不可绑定") name = real_name or user.get("nickname", "") + # 服务商分账添加个人接收方需要"出资特约商户号";优先取该用户已进件的商户绑定 + rows = await _bindings(db).list_by_user(user["id"]) + payer_sub_mchid = next((b.get("sub_mchid") for b in rows + if b.get("bind_type") == "merchant" and b.get("sub_mchid")), "") try: await profitsharing.add_receiver( account_type="PERSONAL_OPENID", account=openid, name=name, + sub_mchid=payer_sub_mchid, ) except RuntimeError as exc: raise ValueError(f"微信添加分账接收方失败:{exc}") from exc @@ -397,8 +402,9 @@ async def bind_merchant(db: Database, user: dict, sub_mchid: str = "", }) if sub_mchid and pay_config.profitsharing_enabled(): try: + # 出资方=OPC 自己的特约商户号(与 release_escrow 分账模型一致:资金冻结在 OPC 账户) await profitsharing.add_receiver( - account_type="MERCHANT_ID", account=sub_mchid, + account_type="MERCHANT_ID", account=sub_mchid, sub_mchid=sub_mchid, ) except RuntimeError as exc: # 接收方添加失败不影响进件记录;置 failed 待人工处理 @@ -464,24 +470,33 @@ async def create_merchant_applyment(db: Database, user: dict, *, contact_info: d raise ValueError(f"微信进件提交失败:{exc}") from exc applyment_id = str(wx.get("applyment_id") or "") - state = wx.get("applyment_state") or "AUDITING" + # 提交接口一般只返回 applyment_id;若返回 state 则规范化(去掉 APPLYMENT_STATE_ 前缀) + state = profitsharing.normalize_applyment_state(wx.get("applyment_state") or "") or "AUDITING" if not applyment_id: raise ValueError("微信未返回进件申请单号,请稍后重试") + # 进件提交阶段微信即可能返回 sign_url(超管扫码完成验证+签约的入口) + sign_url = str(wx.get("sign_url") or "") binding = await repo.create({ "user_id": user["id"], "bind_type": "merchant", "applyment_id": applyment_id, "status": "applying", + "applyment_state": state, "sign_url": sign_url, + "audit_detail_json": json.dumps({"applyment_state_msg": str( + wx.get("applyment_state_msg") or "")}, ensure_ascii=False), }) logger.info("发起特约商户进件 user=%s applyment_id=%s state=%s", user["id"], applyment_id, state) - return {"applyment_id": applyment_id, "applyment_state": state, "binding": binding} + return {"applyment_id": applyment_id, "applyment_state": state, + "sign_url": sign_url, "binding": binding} async def query_merchant_applyment(db: Database, user: dict, binding_id: str) -> dict: - """查询进件状态;审核通过(FINISHED)自动回填子商户号并激活接收方。 + """查询进件状态;按微信 applyment_state 全量映射并落库。 - - FINISHED → 回填 sub_mchid,调微信添加 MERCHANT_ID 接收方(幂等:已存在视为成功)→ active; - - REJECTED → 置 rejected(detail 携带微信驳回原因,前端可引导重提); - - AUDITING / EDITTING / CANCELED → 状态如实返回。 + - FINISHED → 回填 sub_mchid,调微信添加 MERCHANT_ID 接收方 → active; + - TO_BE_SIGNED → 已出 sub_mchid,待超管扫码签约 → 回填 sub_mchid + sign_url(不激活); + - TO_BE_CONFIRMED / SIGNING → 落库 sign_url / account_validation,前端展示扫码引导; + - REJECTED → 置 rejected(落库 audit_detail 驳回原因); + - EDITTING / AUDITING / CANCELED → 状态如实返回。 """ repo = _bindings(db) b = await repo.get(binding_id) @@ -501,33 +516,105 @@ async def query_merchant_applyment(db: Database, user: dict, binding_id: str) -> raise RuntimeError("查询进件状态失败,请稍后重试") state = data.get("applyment_state") or "" sub_mchid = str(data.get("sub_mchid") or "") + sign_url = str(data.get("sign_url") or "") + account_validation = data.get("account_validation") + audit_detail = data.get("audit_detail") + state_msg = data.get("applyment_state_msg") or "" + audit_json = {} + if audit_detail is not None: + audit_json["audit_detail"] = audit_detail + if state_msg: + audit_json["applyment_state_msg"] = state_msg + valid_json = json.dumps(account_validation or {}, ensure_ascii=False) + audit_json_str = json.dumps(audit_json, ensure_ascii=False) if audit_json else "{}" + + def _persist(extra: dict) -> dict: + fields = {"applyment_state": state, "account_validation_json": valid_json, + "audit_detail_json": audit_json_str} + if sign_url: + fields["sign_url"] = sign_url + if sub_mchid: + fields["sub_mchid"] = sub_mchid + fields.update(extra) + return repo.update(binding_id, fields) if state == "FINISHED": if not sub_mchid: raise RuntimeError("进件已通过但未返回子商户号,请稍后重试") - # 添加分账接收方(幂等:微信返回"已存在"视为成功) try: - await profitsharing.add_receiver(account_type="MERCHANT_ID", account=sub_mchid) - b = await repo.update(binding_id, {"sub_mchid": sub_mchid, "status": "active", - "detail": "进件通过,已添加分账接收方"}) + # 出资方=OPC 自己的特约商户号;微信允许出资商户作为自身接收方(分账回留/解冻模型) + await profitsharing.add_receiver( + account_type="MERCHANT_ID", account=sub_mchid, sub_mchid=sub_mchid, + ) + b = await _persist({"status": "active", "detail": "进件通过,已添加分账接收方"}) except RuntimeError as exc: msg = str(exc) if any(k in msg for k in ("已存在", "EXIST", "RECEIVER", "already")): - b = await repo.update(binding_id, {"sub_mchid": sub_mchid, "status": "active", - "detail": "进件通过,接收方已存在"}) + b = await _persist({"status": "active", "detail": "进件通过,接收方已存在"}) else: - b = await repo.update(binding_id, {"sub_mchid": sub_mchid, "status": "failed", - "detail": f"添加接收方失败:{msg[:200]}"}) + b = await _persist({"status": "failed", "detail": f"添加接收方失败:{msg[:200]}"}) logger.info("进件完成回填 user=%s applyment_id=%s sub_mchid=%s state=%s", user["id"], applyment_id, sub_mchid, b["status"]) return {"applyment_id": applyment_id, "applyment_state": state, - "sub_mchid": sub_mchid, "binding": b} + "sub_mchid": sub_mchid, "sign_url": sign_url, "binding": b} + + if state == "TO_BE_SIGNED": + # 已下发子商户号,待超管扫码完成签约授权(账户验证 + 签约一体) + b = await _persist({"status": "applying", + "detail": state_msg or "待超管扫码完成签约授权(含账户验证)"}) + return {"applyment_id": applyment_id, "applyment_state": state, + "sub_mchid": sub_mchid, "sign_url": sign_url, + "account_validation": account_validation, "binding": b} + + if state in profitsharing.APPLYMENT_ACTION_STATES: + # TO_BE_CONFIRMED(待账户验证)/ SIGNING(开通权限中):落库 sign_url 供前端扫码引导 + b = await _persist({"status": "applying", "detail": state_msg or + profitsharing.APPLYMENT_STATE.get(state, state)}) + return {"applyment_id": applyment_id, "applyment_state": state, + "sign_url": sign_url, "account_validation": account_validation, "binding": b} + if state == "REJECTED": - reason = str(data.get("audit_detail") or data.get("reject_reason") or "")[:300] - b = await repo.update(binding_id, {"status": "rejected", "detail": reason or "进件被驳回"}) - return {"applyment_id": applyment_id, "applyment_state": state, "binding": b} + reason = "" + if isinstance(audit_detail, dict): + reasons = audit_detail.get("reject_reason") or [] + reason = ";".join(str(r) for r in reasons) if reasons else "" + reason = reason or state_msg or str(audit_detail or "")[:300] + b = await _persist({"status": "rejected", "detail": reason or "进件被驳回"}) + return {"applyment_id": applyment_id, "applyment_state": state, + "audit_detail": audit_detail, "binding": b} # EDITTING / AUDITING / CANCELED 等:状态如实返回 - return {"applyment_id": applyment_id, "applyment_state": state, "binding": b} + b = await _persist({}) + return {"applyment_id": applyment_id, "applyment_state": state, + "sign_url": sign_url, "binding": b} + + +async def query_split_permission(db: Database, user: dict, binding_id: str) -> dict: + """查询子商户分账授权最大比例(/v3/profitsharing/merchant-configs)。 + + 分账授权需子商户在【商户平台 > 产品中心 > 分账】开通分账并设置允许服务商分账的最大比例 + (默认上限 30%,接口无独立开关字段,以能否查到 max_ratio 为准)。 + 本接口查询微信侧真实配置回填 payment_bindings.split_max_ratio(万分比),供运营/OPC 确认分账可用性。 + """ + repo = _bindings(db) + b = await repo.get(binding_id) + if b is None or b["user_id"] != user["id"]: + raise ValueError("绑定不存在") + if b["bind_type"] != "merchant" or not b.get("sub_mchid"): + raise ValueError("仅已下放子商户号的商户绑定可查询分账授权") + cfg = await profitsharing.query_split_config(sub_mchid=b["sub_mchid"]) + if cfg is None: + # 接口返回非 200(如 403 NO_AUTH)→ 子商户未开通分账或服务商未开通分账权限 + raise RuntimeError("子商户尚未开通分账授权:请子商户在微信商户平台【产品中心>分账】开通," + "并设置允许服务商分账的最大比例;同时确认服务商已开通分账权限") + max_ratio_bp = int(cfg.get("max_ratio") or 0) # 万分比(2000=20%) + max_ratio_pct = float(cfg.get("max_ratio_percent") or 0) # 百分比 + b = await repo.update(binding_id, { + "split_allowed": "OPEN" if max_ratio_bp > 0 else "CLOSED", + "split_max_ratio": max_ratio_bp, + }) + return {"sub_mchid": b["sub_mchid"], "split_allowed": b["split_allowed"], + "split_max_ratio": max_ratio_bp, "split_max_ratio_percent": max_ratio_pct, + "binding": b} async def list_bindings(db: Database, user: dict) -> list[dict]: diff --git a/app/pay/wxpay.py b/app/pay/wxpay.py index f44db69..0dd2f2c 100644 --- a/app/pay/wxpay.py +++ b/app/pay/wxpay.py @@ -120,11 +120,14 @@ async def verify_and_decrypt(headers, body) -> Optional[dict]: # 下单 / 查单 # --------------------------------------------------------------------------- async def create_order(*, client_type: str, out_trade_no: str, total_fen: int, - description: str, openid: str = "") -> dict: - """统一下单。 + description: str, openid: str = "", + profit_sharing: bool = False) -> dict: + """统一下单(直连商户)。 - native → {"code_url": ...}(桌面端扫码) - jsapi → {"pay_params": {appId,timeStamp,nonceStr,package,signType,paySign}}(小程序) + - profit_sharing=True → 订单带分账标记(settle_info.profit_sharing),支付成功后 + 资金在商户侧冻结、可分账(服务商分账走 profitsharing.create_partner_order)。 失败抛 RuntimeError(携带微信返回报文摘要)。 """ if not await _ensure_wxpay(): @@ -147,6 +150,7 @@ async def create_order(*, client_type: str, out_trade_no: str, total_fen: int, payer=payer, pay_type=pay_type, appid=appid, + settle_info={"profit_sharing": True} if profit_sharing else None, ) if code != 200: raise RuntimeError(f"微信下单失败 http={code}: {str(result)[:300]}") diff --git a/app/services/settlement_service.py b/app/services/settlement_service.py index 1b6962b..22b75f4 100644 --- a/app/services/settlement_service.py +++ b/app/services/settlement_service.py @@ -36,8 +36,18 @@ class SettlementService: def __init__(self, db: Database): self.db = db - async def release_escrow(self, task_id: str) -> dict: - """验收通过后结算:优先微信服务商分账,不满足条件时降级台账结算。""" + async def release_escrow(self, task_id: str, *, + transaction_id: str = "", payer_sub_mchid: str = "") -> dict: + """验收通过后结算:优先微信服务商分账(资金冻结→分账→解冻剩余),不满足条件降级台账结算。 + + 完整资金生命周期(微信服务商分账,出资方=接单者特约商户): + 1. 冻结:订单带 profit_sharing=true 支付成功后资金冻结在出资子商户不可用余额; + 业务侧 escrow 置 frozen + 记录 transaction_id / payer_sub_mchid; + 2. 查询剩余待分金额(确认可分金额); + 3. 分账:平台佣金 5% 分给服务商商户号(部分分账 unfreeze_unsplit=False); + 4. 查询分账结果(state=FINISHED 且 receivers.result=SUCCESS); + 5. 解冻剩余:调分账完结接口把剩余 95% 解冻回出资方(接单者)账户 → escrow released。 + """ task = await self.db.tasks.get(task_id) if task is None or task["status"] != "completed": raise HTTPException(status_code=400, detail="任务未完成,不能结算") @@ -48,65 +58,177 @@ class SettlementService: commission = int(amount * COMMISSION_RATE) esc = await self.db.escrows.create(task_id, task["title"], amount, commission) + # 冻结确认:外部传入微信交易单号 + 出资商户号 → 记录并置 frozen + if transaction_id: + esc = await self.db.escrows.update_fields(esc["id"], { + "channel": "wx_split", "transaction_id": transaction_id, + "payer_sub_mchid": payer_sub_mchid, + "share_status": "pending", + }) + if esc["status"] in ("deposited", "released"): + esc = await self.db.escrows.set_status(esc["id"], "frozen") + await self._log(esc, "wx_split", "托管资金已冻结(微信支付分账冻结)", "initiated", + f"tx={transaction_id} payer={payer_sub_mchid}") + logger.info("托管资金冻结确认 task=%s tx=%s payer=%s", task_id, transaction_id, payer_sub_mchid) + tx_id = esc.get("transaction_id") or "" if not tx_id or not pay_config.profitsharing_enabled(): # 降级:未接微信收单 / 服务商分账未配置 → 台账结算 - esc = await self.db.escrows.update_fields( - esc["id"], {"channel": "manual", "share_status": "manual"}) - esc = await self.db.escrows.set_status(esc["id"], "released") - await self._log(esc, "manual", "未接入微信收单或分账未配置,台账结算", "shared", - f"no_transaction_id={not tx_id}") - return esc + return await self._manual_release(esc, "未接入微信收单或分账未配置,台账结算", + f"no_transaction_id={not tx_id}") receiver, reason = await self._resolve_receiver(task) if receiver is None: - esc = await self.db.escrows.update_fields( - esc["id"], {"channel": "manual", "share_status": "manual"}) - esc = await self.db.escrows.set_status(esc["id"], "released") - await self._log(esc, "manual", "接单者无收款绑定,台账结算", "shared", reason) - return esc + return await self._manual_release(esc, "接单者无收款绑定,台账结算", reason) + if not (receiver.get("sub_mchid") or ""): + return await self._manual_release(esc, "接单者绑定无出资商户号(sub_mchid),台账结算", + f"binding={receiver.get('id')}") - # 发起微信分账:金额(元→分);接单者 = 总额 - 佣金,平台佣金单独分给服务商商户号 + return await self._split_lifecycle(esc, receiver) + + async def _manual_release(self, esc: dict, note: str, detail: str = "") -> dict: + """降级台账结算(不碰真金,供对账补齐)。""" + esc = await self.db.escrows.update_fields( + esc["id"], {"channel": "manual", "share_status": "manual"}) + esc = await self.db.escrows.set_status(esc["id"], "released") + await self._log(esc, "manual", note, "shared", detail) + return esc + + async def _split_lifecycle(self, esc: dict, receiver: dict) -> dict: + """微信服务商分账完整生命周期:剩余待分确认 → 分账 → 结果确认 → 解冻剩余。""" + sub_mchid = receiver.get("sub_mchid") or "" + tx_id = esc.get("transaction_id") or "" + if not tx_id or not sub_mchid: + return await self._manual_release(esc, "分账前置缺失(transaction_id/sub_mchid)", + f"tx={tx_id} sub_mchid={sub_mchid}") + + # ── 1) 查询剩余待分金额(确认冻结资金)── + remaining = await profitsharing.query_remaining_amount(transaction_id=tx_id) + if remaining is None: + return await self._manual_release(esc, "查询剩余待分金额失败,台账结算", "amount_query_failed") + unsplit_fen = int(remaining.get("unsplit_amount") or 0) total_fen = int(esc["amount"]) * 100 - commission_fen = int(esc["commission"]) * 100 - opc_fen = total_fen - commission_fen + if unsplit_fen <= 0: + return await self._manual_release(esc, "订单无可分金额(可能已分账/已解冻)", + f"unsplit={unsplit_fen}") + # 以微信侧实际可分金额为准(防超分),但不超过台账总额 + usable_fen = min(unsplit_fen, total_fen) + commission_fen = min(int(esc["commission"]) * 100, usable_fen) + opc_remain_fen = usable_fen - commission_fen + + # ── 2) 发起分账:平台佣金 5% 分给服务商商户号(部分分账,剩余保留待解冻)── share_no = f"PS_{int(time.time())}_{secrets.token_hex(4).upper()}" - receivers = [ - { - "type": "MERCHANT_ID" if receiver["bind_type"] == "merchant" else "PERSONAL_OPENID", - "account": receiver.get("sub_mchid") or receiver.get("openid"), - "amount": max(opc_fen, 0), - "description": "任务服务费", - }, + split_receivers = [ { "type": "MERCHANT_ID", "account": pay_config.WECHATPAY_SP_MCHID, - "amount": commission_fen, - "description": "平台服务费", + "amount": max(commission_fen, 0), + "description": "平台服务费(分账)", }, ] try: await profitsharing.create_split( - transaction_id=tx_id, out_order_no=share_no, receivers=receivers, - sub_mchid=receiver.get("sub_mchid") or "", + transaction_id=tx_id, out_order_no=share_no, + receivers=split_receivers, sub_mchid=sub_mchid, + unfreeze_unsplit=False, ) except RuntimeError as exc: esc = await self.db.escrows.update_fields(esc["id"], { "channel": "wx_split", "share_order_no": share_no, "share_status": "failed", "receiver_binding_id": receiver["id"], + "split_detail": json.dumps({"step": "split_request", "error": str(exc)[:300]}, + ensure_ascii=False), }) - await self._log(esc, "wx_split", "分账发起失败", "failed", str(exc)[:300]) + await self._log(esc, "wx_split", "分账发起失败", "failed", str(exc)[:300], + split_receivers) raise HTTPException(status_code=502, detail=f"分账发起失败:{exc}") from exc esc = await self.db.escrows.update_fields(esc["id"], { "channel": "wx_split", "share_order_no": share_no, "share_status": "sharing", "receiver_binding_id": receiver["id"], + "split_detail": json.dumps({ + "step": "split_initiated", "unsplit_fen": unsplit_fen, + "commission_fen": commission_fen, "opc_remain_fen": opc_remain_fen, + }, ensure_ascii=False), }) - await self._log(esc, "wx_split", "分账已发起,等待微信回调确认", "initiated", - receivers_json=receivers) - logger.info("分账已发起 task=%s share=%s receiver=%s", task_id, share_no, receiver["id"]) + await self._log(esc, "wx_split", "分账已发起(平台佣金5%),等待结果", "initiated", + f"share={share_no}", split_receivers) + + # ── 3) 查询分账结果(微信异步:PROCESSING → FINISHED)── + split_result = await profitsharing.query_split( + transaction_id=tx_id, out_order_no=share_no, sub_mchid=sub_mchid) + ok, fail_reason = profitsharing.split_finished(split_result) if split_result else (False, "无分账结果") + if not ok: + # PROCESSING:等微信回调(handle_split_notify)或后续轮询;不置终态 + logger.info("分账处理中/未确认 task=%s share=%s reason=%s", + esc["task_id"], share_no, fail_reason) + return esc + + # ── 4) 分账成功 → 解冻剩余资金回出资方(接单者),完结分账 ── + unfreeze_no = f"UF_{int(time.time())}_{secrets.token_hex(4).upper()}" + try: + await profitsharing.unfreeze_remaining( + transaction_id=tx_id, out_order_no=unfreeze_no, + sub_mchid=sub_mchid, description="任务验收通过,解冻剩余资金", + ) + except RuntimeError as exc: + esc = await self.db.escrows.update_fields(esc["id"], { + "share_status": "failed", + "split_detail": json.dumps({"step": "unfreeze", "error": str(exc)[:300]}, + ensure_ascii=False), + }) + await self._log(esc, "wx_split", "分账成功但解冻剩余失败", "failed", str(exc)[:300]) + raise HTTPException(status_code=502, detail=f"解冻剩余资金失败:{exc}") from exc + + esc = await self.db.escrows.update_fields(esc["id"], { + "share_status": "shared", + "split_detail": json.dumps({ + "step": "completed", "share_no": share_no, "unfreeze_no": unfreeze_no, + "commission_fen": commission_fen, "opc_remain_fen": opc_remain_fen, + "wx_split_result": split_result.get("order_id", ""), + }, ensure_ascii=False), + }) + esc = await self.db.escrows.set_status(esc["id"], "released") + await self._log(esc, "wx_split", "分账完成+解冻剩余,资金已结算", "shared", + f"share={share_no} unfreeze={unfreeze_no}", split_receivers) + logger.info("分账生命周期完成 task=%s share=%s unfreeze=%s", + esc["task_id"], share_no, unfreeze_no) return esc + async def query_split_lifecycle(self, task_id: str) -> dict: + """查询任务分账生命周期状态(供前端/轮询展示:冻结→分账→解冻)。""" + escrows = await self.db.escrows.list() + esc = next((e for e in escrows if e["task_id"] == task_id), None) + if esc is None: + return {"exists": False} + detail = {} + try: + detail = json.loads(esc.get("split_detail") or "{}") + except (ValueError, TypeError): + detail = {} + out = {"exists": True, "escrow_id": esc["id"], "task_id": task_id, + "amount": esc["amount"], "commission": esc["commission"], + "status": esc["status"], "channel": esc["channel"], + "transaction_id": esc.get("transaction_id", ""), + "payer_sub_mchid": esc.get("payer_sub_mchid", ""), + "share_order_no": esc.get("share_order_no", ""), + "share_status": esc.get("share_status", ""), + "split_detail": detail} + # 分账处理中时主动查询微信侧最新结果(供轮询收敛) + if esc["status"] == "frozen" and esc.get("share_order_no") and esc.get("transaction_id"): + wx = await profitsharing.query_split( + transaction_id=esc["transaction_id"], + out_order_no=esc["share_order_no"], + sub_mchid=esc.get("payer_sub_mchid", ""), + ) + if wx: + ok, reason = profitsharing.split_finished(wx) + out["wx_split_state"] = wx.get("state", "") + out["wx_split_finished"] = ok + out["wx_split_reason"] = reason + out["wx_split_receivers"] = wx.get("receivers", []) + return out + async def handle_split_notify(self, result: dict) -> bool: """分账结果回调:微信确认分账成功 → escrow released + 流水 shared(幂等)。""" share_no = result.get("out_order_no", "") diff --git a/app/services/task_service.py b/app/services/task_service.py index e3929d8..9080966 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -6,6 +6,7 @@ Repository(经 Database 门面),不反向依赖接口层。 """ from __future__ import annotations +import asyncio import json from fastapi import HTTPException @@ -13,6 +14,16 @@ from fastapi import HTTPException from ..infrastructure.repositories import Database +def _sync_task_group(task_id: str) -> None: + """任务群后台同步(fire-and-forget,不阻断主流程;IM 服务不可用时静默降级)。""" + try: + from ..im import client as im_client + + asyncio.create_task(im_client.sync_task_group(task_id)) + except Exception: # noqa: BLE001 + pass + + class TaskService: """统一任务状态流转:grab/bid/deliver/win/review。""" @@ -107,6 +118,7 @@ class TaskService: task_id, actor["id"], actor.get("nickname") or actor.get("username", ""), source, ) + _sync_task_group(task_id) return updated async def assign(self, task_id: str, taker_user_id: str, actor: dict) -> dict: @@ -124,6 +136,7 @@ class TaskService: await self.db.task_claims.create( task_id, taker_user_id, taker_name, source="assign", status="assigned", ) + _sync_task_group(task_id) return updated async def recommend(self, task_id: str, candidates: list[str], actor: dict) -> list[dict]: @@ -163,6 +176,7 @@ class TaskService: await self.db.task_claims.create( task_id, taker_user_id, taker_name, source="recommend", status="assigned", ) + _sync_task_group(task_id) return updated async def start_doing(self, task_id: str, actor: dict) -> dict: @@ -242,7 +256,9 @@ class TaskService: chosen = await self.db.task_claims.get(claim_id) if chosen is None: raise HTTPException(status_code=404, detail="报名记录不存在") - return await self.db.tasks.claim(task_id, chosen.get("claimer_user_id") or "") + result = await self.db.tasks.claim(task_id, chosen.get("claimer_user_id") or "") + _sync_task_group(task_id) + return result async def deliver(self, task_id: str, actor: dict) -> dict: """交付:任务存在且处于进行中(in_progress)才可交付 → delivered。 @@ -266,7 +282,9 @@ class TaskService: if bid is None or bid["task_id"] != task_id: raise HTTPException(status_code=404, detail="竞标不存在") await self.db.bids.set_status(bid_id, "win") - return await self.db.tasks.set_status(task_id, "in_progress") + result = await self.db.tasks.set_status(task_id, "in_progress") + _sync_task_group(task_id) + return result async def review(self, task_id: str, accept: bool) -> dict: """企业验收:accept → completed;reject → in_progress。""" diff --git a/dispatcher.py b/dispatcher.py index 285097a..9c729f8 100644 --- a/dispatcher.py +++ b/dispatcher.py @@ -24,6 +24,10 @@ ROUTE_PARK_PREFIX = "/park" def _prefix_app(path: str): """按路径前缀匹配目标子应用;未命中返回 None 交由平台应用(app.main)。""" + # OPC 收款认证/分账/进件接口:pay_router 挂载在平台应用(app.main),路径 /opc/pay/* + # 前端统一走 /api 前缀,故 /api/opc/pay/* 需例外路由到平台应用(而非 training 子应用) + if path.startswith("/api/opc/pay/") or path == "/api/opc/pay": + return core_app if path.startswith(ROUTE_PARK_PREFIX): return park_app if path.startswith(ROUTE_TRAINING_PREFIXES): diff --git a/serverrun/mqtt/docker-compose.yml b/serverrun/mqtt/docker-compose.yml index 1b2c816..959b71c 100644 --- a/serverrun/mqtt/docker-compose.yml +++ b/serverrun/mqtt/docker-compose.yml @@ -1,23 +1,65 @@ # MQTT 消息服务(EMQX,大屏/桌面端实时总线) +name: opc-emqx + services: emqx: - image: emqx/emqx:5.8 + # image: 192.168.1.3:9998/pinesound/emqx:latest + image: emqx/emqx:latest container_name: opc-emqx - restart: always - ports: - - "127.0.0.1:1883:1883" # MQTT - - "127.0.0.1:18083:18083" # 管理 API - - "127.0.0.1:8083:8083" # WebSocket + pull_policy: always + restart: unless-stopped environment: - - EMQX_NAME=opc - - EMQX_HOST=node1.emqx.local + # EMQX 集群名称 + EMQX_CLUSTER__NAME: opc_cluster + # 节点名称 + EMQX_NODE__NAME: emqx@127.0.0.1 + # Dashboard 管理员密码 + EMQX_DASHBOARD__DEFAULT_PASSWORD: "WSSmine4ever" + # MQTT TCP 监听器认证 + EMQX_LISTENERS__TCP__DEFAULT__PROXY_PROTOCOL: false + # 内置数据库认证(MQTT 客户端连接用户名密码) + EMQX_AUTHENTICATION__BUILTIN__USERS: | + [ + { + "username": "opc", + "password": "Opc123!" + } + ] + # 允许匿名连接(设为 false 强制认证) + EMQX_ALLOW_ANONYMOUS: false + # MQTT 最大客户端数 + EMQX_LISTENERS__TCP__DEFAULT__MAX_CONNECTIONS: 102400 + # 日志等级 + EMQX_LOG__LEVEL: info + ports: + # MQTT TCP + - "1883:1883" + # MQTT SSL + - "8883:8883" + # MQTT WebSocket + - "8083:8083" + # MQTT WebSocket SSL + - "8084:8084" + # Dashboard API(Web 管理控制台) + - "18083:18083" volumes: + # 持久化 EMQX 数据 - ../../serverdata/emqx-data:/opt/emqx/data + # 持久化 EMQX 日志 - ../../serverdata/emqx-log:/opt/emqx/log + + user: "0:0" # 临时用 root 运行,彻底解决权限 + + healthcheck: + test: ["CMD", "emqx", "ctl", "status"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 30s networks: - opc-network networks: opc-network: name: opc-network - external: true + external: true \ No newline at end of file diff --git a/wechatpayv3/async_/media.py b/wechatpayv3/async_/media.py index 1ae9c8f..3a98a68 100644 --- a/wechatpayv3/async_/media.py +++ b/wechatpayv3/async_/media.py @@ -43,7 +43,7 @@ async def image_upload(self, filepath, filename=None): :param filepath: 图片文件路径 :param filename: 文件名称,未指定则从filepath参数中截取 """ - return _media_upload(self, filepath, filename, path='/v3/merchant/media/upload') + return await _media_upload(self, filepath, filename, path='/v3/merchant/media/upload') async def video_upload(self, filepath, filename=None): @@ -51,4 +51,4 @@ async def video_upload(self, filepath, filename=None): :param filepath: 视频文件路径 :param filename: 文件名称,未指定则从filepath参数中截取 """ - return _media_upload(self, filepath, filename, path='/v3/merchant/media/video_upload') + return await _media_upload(self, filepath, filename, path='/v3/merchant/media/video_upload')