Explorar el Código

chore: update graph and docs after OA WeChat notify integration

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu hace 2 meses
padre
commit
75ef98743d

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 399 - 1904
backend/graphify-out/.graphify_analysis.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 158 - 158
backend/graphify-out/.graphify_extract.json


+ 98 - 118
backend/graphify-out/GRAPH_REPORT.md

@@ -1,11 +1,11 @@
 # Graph Report - app  (2026-08-13)
 
 ## Corpus Check
-- Large corpus: 250 files · ~303,618 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
+- Large corpus: 253 files · ~304,806 words. Semantic extraction will be expensive (many Claude tokens). Consider running on a subfolder, or use --no-semantic to run AST-only.
 
 ## Summary
-- 5240 nodes · 28677 edges · 46 communities detected
-- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 19614 edges (avg confidence: 0.6)
+- 5267 nodes · 28754 edges · 42 communities detected
+- Extraction: 32% EXTRACTED · 68% INFERRED · 0% AMBIGUOUS · INFERRED: 19652 edges (avg confidence: 0.6)
 - Token cost: 0 input · 0 output
 
 ## Community Hubs (Navigation)
@@ -45,21 +45,17 @@
 - [[_COMMUNITY_Community 33|Community 33]]
 - [[_COMMUNITY_Community 34|Community 34]]
 - [[_COMMUNITY_Community 35|Community 35]]
-- [[_COMMUNITY_Community 36|Community 36]]
-- [[_COMMUNITY_Community 37|Community 37]]
 - [[_COMMUNITY_Community 38|Community 38]]
 - [[_COMMUNITY_Community 39|Community 39]]
+- [[_COMMUNITY_Community 40|Community 40]]
+- [[_COMMUNITY_Community 41|Community 41]]
 - [[_COMMUNITY_Community 42|Community 42]]
 - [[_COMMUNITY_Community 43|Community 43]]
-- [[_COMMUNITY_Community 44|Community 44]]
-- [[_COMMUNITY_Community 45|Community 45]]
-- [[_COMMUNITY_Community 46|Community 46]]
-- [[_COMMUNITY_Community 47|Community 47]]
 
 ## God Nodes (most connected - your core abstractions)
-1. `success_response()` - 736 edges
-2. `error_response()` - 609 edges
-3. `SysUser` - 598 edges
+1. `success_response()` - 738 edges
+2. `error_response()` - 611 edges
+3. `SysUser` - 600 edges
 4. `BizQcTaskResult` - 346 edges
 5. `BizProductionMaterial` - 329 edges
 6. `BizProductionMaterialUsage` - 323 edges
@@ -73,10 +69,10 @@
   core/api_request_log_middleware.py → models/models.py
 - `get_menu_list()` --calls--> `success_response()`  [INFERRED]
   api/menu.py → core/response.py
-- `中试服务管理 API(细胞产品类型/规格/价格体系/项目价格、渠道信息、体检机构)` --uses--> `SysUser`  [INFERRED]
-  api/pilot_service.py → models/models.py
 - `微信小程序登录业务:openid + 手机号一致性校验。` --uses--> `SysUser`  [INFERRED]
   services/wechat_login_service.py → models/models.py
+- `已绑定 openid 时静默换票(无需手机号授权)。` --uses--> `SysUser`  [INFERRED]
+  services/wechat_login_service.py → models/models.py
 - `根据 openid + 本次授权手机号解析可登录员工。      Returns:         (user, error_message, error_co` --uses--> `SysUser`  [INFERRED]
   services/wechat_login_service.py → models/models.py
 
@@ -84,232 +80,216 @@
 
 ### Community 0 - "Community 0"
 Cohesion: 0.01
-Nodes (1032): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+1024 more)
+Nodes (1073): attachment_download_url(), create_dept(), create_dict_data(), create_dict_type(), create_express_company(), create_item_unit(), create_menu(), create_post() (+1065 more)
 
 ### Community 1 - "Community 1"
 Cohesion: 0.01
-Nodes (746): _ensure_customer_for_mother(), api_save_application(), set_current_process_route(), generate_uuid(), allocate_next_biz_no(), _format_biz_no(), _prefix_for(), 业务协议号自动生成:XBCC / JZCC + YYYYMMDD + 6 位序号 (+738 more)
+Nodes (740): generate_uuid(), allocate_next_biz_no(), _format_biz_no(), _prefix_for(), 业务协议号自动生成:XBCC / JZCC + YYYYMMDD + 6 位序号, 原子分配下一个业务协议号。     须在 create 事务内调用;使用 MySQL LAST_INSERT_ID 保证并发安全。, create_cell_sample_type(), get_cell_sample_type() (+732 more)
 
 ### Community 2 - "Community 2"
 Cohesion: 0.01
-Nodes (599): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+591 more)
+Nodes (608): _normalize_date_bound(), _persist_protocol_sub_resources(), list_ethnics_for_select(), apply_data_scope(), _get_child_dept_ids(), get_user_dept_id(), _resolve_user_role_scopes(), eq_unicode() (+600 more)
 
 ### Community 3 - "Community 3"
-Cohesion: 0.07
-Nodes (476): 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), Base, 按输注计划 ID 批量索引缴费记录(列表页展示用,不做关联 enrich), 输注计划创建时自动调用,生成 dfj 缴费记录, 关联查询申请单、患者、输注计划信息,附加到 dict, 将 BizMaterialUsage 行转换为字典(用于我的领用记录)。, 当前登录人的手动出库领用记录:领用人字段存昵称或用户 ID(与生产/质检一致)。, 获取当前用户的物料领用记录(手动出库记录)      筛选条件:     - tenant_no = 当前用户租户     - source_type = 'm (+468 more)
+Cohesion: 0.01
+Nodes (567): AttachmentDownloadUrlBody, DeptCreateBody, DeptUpdateBody, DictDataCreateBody, DictDataUpdateBody, DictTypeCreateBody, DictTypeUpdateBody, ExpressCompanyCreateBody (+559 more)
 
 ### Community 4 - "Community 4"
-Cohesion: 0.01
-Nodes (320): _normalize_date_bound(), _persist_protocol_sub_resources(), batch_delete_research_donations(), change_donation_status(), convert_from_storage(), convert_to_storage(), create_research_donation(), delete_research_donation() (+312 more)
+Cohesion: 0.07
+Nodes (426): 环境检测 — 检测项维护 + 登记管理 API, 保险公司及险种价格管理 API(保险公司 + 开票信息 + 险种价格), 宫格图标底色/图形色(可按入口自定义,不强制品牌绿)。, ProcessDefCreateBody, ProcessDefUpdateBody, PublishVersionBody, 审批意见:前端传 commentText,兼容 comment。, StartInstanceBody (+418 more)
 
 ### Community 5 - "Community 5"
-Cohesion: 0.03
-Nodes (238): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _copied_app_name(), copy_app(), create_app(), create_group(), _create_version() (+230 more)
+Cohesion: 0.07
+Nodes (318): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+310 more)
 
 ### Community 6 - "Community 6"
 Cohesion: 0.02
-Nodes (244): eq_unicode(), like_unicode(), MySQL 字符串比较/模糊匹配时统一排序规则,避免 Illegal mix of collations。, 跨表字符串等值比较(JOIN / WHERE)时统一为 utf8mb4_unicode_ci。, 将列显式设为 utf8mb4_unicode_ci。, unicode_ci(), batch_review(), _dec() (+236 more)
+Nodes (154): get_designer_data(), _error_json(), lifespan(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware (+146 more)
 
 ### Community 7 - "Community 7"
-Cohesion: 0.02
-Nodes (146): get_designer_data(), _error_json(), lifespan(), 未捕获异常:统一 JSON 500,确保跨域场景下仍有 Access-Control-Allow-Origin, 数据库异常:返回 JSON 并保留 CORS 头,避免浏览器只报跨域错误, sqlalchemy_exception_handler(), unhandled_exception_handler(), BaseHTTPMiddleware (+138 more)
+Cohesion: 0.03
+Nodes (161): 截止日期:仅日期时扩到当天 23:59:59。, 发起前预览审批链(条件齐后解析最终路径与审批人)。, 兼容旧 GET:无 formData,含条件分支时多为待确定占位。, _copied_app_name(), copy_app(), create_app(), create_group(), _create_version() (+153 more)
 
 ### Community 8 - "Community 8"
-Cohesion: 0.08
-Nodes (185): CellSampleTypeCreateBody, CellSampleTypeUpdateBody, CollectionRegionCreateBody, CollectionRegionUpdateBody, CustomerCreateBody, CustomerUpdateBody, HospitalCreateBody, HospitalUpdateBody (+177 more)
+Cohesion: 0.03
+Nodes (169): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, list_storage_slot_stats(), OccupancyCreateBody (+161 more)
 
 ### Community 9 - "Community 9"
 Cohesion: 0.02
-Nodes (163): get_next_cell_product_code(), build_void_type_label_map(), format_protocol_status_label(), resolve_void_type_label(), _ancestor_link_from_cell_product_input(), _ancestor_sample_chain_link(), _apply_cell_product_status_change(), _build_ancestor_chain() (+155 more)
+Nodes (143): mp_bind(), mp_bind_status(), 小程序鉴权:微信登录 / 绑定 openid。, 当前用户是否已绑定微信服务号 openid。, 服务号网页授权 code 绑定当前用户(需 Bearer)。, 微信登录:openid 已绑定时可静默换票;否则须授权手机号且与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, _resolve_user_id() (+135 more)
 
 ### Community 10 - "Community 10"
-Cohesion: 0.03
-Nodes (117): 小程序鉴权:微信登录 / 绑定 openid。, 微信授权登录:须授权手机号;openid 已绑定也须与账号 phone_no 一致。, 密码登录后绑定当前微信 openid(需 Bearer)。, _resolve_user_id(), wechat_bind(), wechat_login(), _collect_descendants(), _current_user() (+109 more)
+Cohesion: 0.05
+Nodes (78): api_save_application(), auto_generate_schedules(), complete_application(), create_application(), create_basic_experiment_sale_schedules(), create_infusion_plan(), create_schedule(), _create_schedule_products() (+70 more)
 
 ### Community 11 - "Community 11"
-Cohesion: 0.05
-Nodes (109): AccessLogCreateBody, AccessLogUpdateBody, CleanRoomCreateBody, CleanRoomUpdateBody, ExhibitionAppointmentCreateBody, ExhibitionAppointmentUpdateBody, OccupancyCreateBody, 运营支撑 API(洁净室、进出记录、仓储设施、储位、展厅预约) (+101 more)
+Cohesion: 0.45
+Nodes (71): BatchStockInBody, BatchStockInItem, BatchStockOutBody, BatchStockOutItem, ConsumableCreateBody, ConsumablePackCreateBody, ConsumablePackUpdateBody, ConsumableUpdateBody (+63 more)
 
 ### Community 12 - "Community 12"
-Cohesion: 0.38
-Nodes (78): BatchStockInBody, BatchStockInItem, BatchStockOutBody, BatchStockOutItem, ConsumableCreateBody, ConsumablePackCreateBody, ConsumablePackUpdateBody, ConsumableUpdateBody (+70 more)
+Cohesion: 0.06
+Nodes (70): add_task_step(), _aggregate_production_tab_step_field(), assert_step_assignee_for_write(), assert_task_assignee(), assert_task_step_assignee_or_legacy(), _base_active_step_count_query(), _batch_load_step_sub_tables(), _clone_route_to_task() (+62 more)
 
 ### Community 13 - "Community 13"
-Cohesion: 0.07
-Nodes (54): create_leave(), get_leave(), list_leaves(), _row_to_dict(), soft_delete_leave(), _dec(), _fmt_dt(), list_stock_logs() (+46 more)
+Cohesion: 0.11
+Nodes (43): code_exists(), create_material(), _dec(), _filter_material_type(), _fmt_dt(), get_material(), list_materials(), _norm_material_type() (+35 more)
 
 ### Community 14 - "Community 14"
-Cohesion: 0.09
-Nodes (53): batch_stock_in(), batch_stock_out(), code_exists(), create_material(), _dec(), _filter_material_type(), _fmt_dt(), get_material() (+45 more)
-
-### Community 15 - "Community 15"
 Cohesion: 0.12
 Nodes (11): get_wechat_pay_client(), get_wechat_pay_v2(), 微信支付工具模块  支持微信支付 V3 API 文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.ht, 统一下单接口 (V3)          Args:             out_trade_no: 商户订单号             total, 申请退款          Args:             out_trade_no: 商户订单号             total_amount, 小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致), 统一下单 (V2 API)          Args:             out_trade_no: 商户订单号             tot, 生成签名 V3 (SHA256 with RSA) (+3 more)
 
-### Community 16 - "Community 16"
-Cohesion: 0.13
-Nodes (20): 微信消息通知服务  支持微信小程序模板消息推送  使用方式:     from app.core.wechat_message import Wech, 发送模板消息                  Args:             openid: 用户openid             templ, send_delivery_notice(), send_delivery_warning(), send_order_status(), send_payment_success(), send_refund_result(), send_urge_notification() (+12 more)
-
-### Community 17 - "Community 17"
-Cohesion: 0.14
-Nodes (22): save_patient(), batch_create_checkups(), create_checkup(), delete_actual_item(), delete_reserve_item(), ensure_checkup_for_patient(), get_checkup_by_application(), get_checkup_by_id() (+14 more)
+### Community 15 - "Community 15"
+Cohesion: 0.19
+Nodes (27): _amount_for_row(), _batch_map(), deduct_for_task(), _deduct_task_usage_legacy_batch(), _display_remark(), _fmt_date_val(), _linked_qc_task_no_map(), list_linked_task_usages() (+19 more)
 
-### Community 18 - "Community 18"
+### Community 16 - "Community 16"
 Cohesion: 0.19
 Nodes (21): apply_overtime_kit_hours(), _build_rest_day_segments(), _build_workday_windows(), calc_overtime_hours(), _day_at_minutes(), _day_start_ms(), _extract_rules(), _format_ymd() (+13 more)
 
-### Community 19 - "Community 19"
+### Community 17 - "Community 17"
 Cohesion: 0.21
 Nodes (19): _consumable_row_to_dict(), create_process(), _create_sub_consumable(), _create_sub_data_collection(), _create_sub_output(), _create_sub_reagent(), _data_collection_row_to_dict(), get_process() (+11 more)
 
-### Community 20 - "Community 20"
+### Community 18 - "Community 18"
 Cohesion: 0.2
 Nodes (19): _code_exists(), create_material(), _dec(), _fmt_dt(), get_material(), get_material_entity(), get_my_usages(), _is_enabled_status() (+11 more)
 
+### Community 19 - "Community 19"
+Cohesion: 0.2
+Nodes (14): add_days_yyyymmdd(), add_interval_months_yyyymmdd(), add_months_yyyymmdd(), calc_fq_installment_due_date(), calc_installment_due_amounts_by_ratios(), _calc_installment_due_amounts_inline(), calc_unpaid_amounts_by_ratios(), is_legacy_installment_ratios() (+6 more)
+
+### Community 20 - "Community 20"
+Cohesion: 0.27
+Nodes (12): create_equipment(), _dec(), equipment_code_exists(), equipment_is_referenced(), get_equipment(), list_equipments(), _norm_status(), _parse_date() (+4 more)
+
 ### Community 21 - "Community 21"
-Cohesion: 0.25
-Nodes (18): _amount_for_row(), _batch_map(), _display_remark(), _fmt_date_val(), list_stock_check_requisitions(), list_usages(), _list_usages_query(), mark_requisition_fully_used() (+10 more)
+Cohesion: 0.27
+Nodes (11): _accept_conclusion_label(), _base_query(), _ensure_notification(), _fmt_dt(), generate_sms_content(), list_notifications(), _normalize_date_bound(), resolve_notification() (+3 more)
 
 ### Community 22 - "Community 22"
-Cohesion: 0.21
-Nodes (16): _field_label(), _is_non_empty_str(), _is_required(), _is_valid_receive_account_item(), normalize_account_fields(), _pick(), _pick_order_num(), _pick_str() (+8 more)
+Cohesion: 0.24
+Nodes (9): 微信消息通知服务  支持微信小程序模板消息推送  使用方式:     from app.core.wechat_message import Wech, 发送模板消息                  Args:             openid: 用户openid             templ, send_delivery_notice(), send_delivery_warning(), send_order_status(), send_payment_success(), send_refund_result(), send_urge_notification() (+1 more)
 
 ### Community 23 - "Community 23"
-Cohesion: 0.22
-Nodes (15): apply_trip_kit_days(), calc_trip_leg_days(), _day_index(), _end_offset(), _extract_rules(), is_half_day_multiple(), 出差套件:半日制行程时长(与前端 oa-trip-days.ts 对齐), 遍历 tripKit:行程未手改则覆盖 duration;出差天数未手改则覆盖为各行程时长之和。 (+7 more)
+Cohesion: 0.27
+Nodes (12): _field_label(), _is_empty(), _is_required(), _parse_money(), OA 表单设计器:金额控件 form_data 校验, 校验 form_data 中金额控件的值格式,返回中文错误列表(空表示通过)。, 解析金额值。成功返回 (number, None);失败返回 (None, error_key)。, _rule_props() (+4 more)
 
 ### Community 24 - "Community 24"
-Cohesion: 0.26
-Nodes (14): apply_leave_kit_hours(), calc_leave_hours(), _extract_rules(), _kit_child_fields(), _overlap_minutes_on_day(), _parse_dt(), _parse_hm(), 请假套件:双段工时重叠小时(与前端 oa-leave-hours.ts 对齐) (+6 more)
-
-### Community 25 - "Community 25"
 Cohesion: 0.24
 Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 试剂变动记录 CRUD(biz_reagent_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减试剂库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
 
-### Community 26 - "Community 26"
+### Community 25 - "Community 25"
 Cohesion: 0.24
 Nodes (11): deduct_for_step(), _display_remark(), list_stock_logs(), _parse_receiver_purpose_from_remark(), 耗材变动记录 CRUD(biz_consumable_stock_log), 撤销步骤此前 step_save 扣减,便于重新填报。, 扣减耗材库存并生成变动记录。调用方控制事务,本函数不 commit。, _resolve_receiver_purpose() (+3 more)
 
-### Community 27 - "Community 27"
-Cohesion: 0.27
-Nodes (11): _build_tree(), _cascade_soft_delete(), _collect_with_ancestors(), create_dept(), get_dept(), list_depts(), list_depts_tree(), 保留匹配节点及其全部上级,供关键词检索后仍能正确组树。 (+3 more)
-
-### Community 28 - "Community 28"
+### Community 26 - "Community 26"
 Cohesion: 0.31
 Nodes (10): create_account(), _filtered_query(), get_account(), list_accounts(), list_options(), OA 收款账户 CRUD(oa_receive_account), row_to_dict(), soft_delete_account() (+2 more)
 
-### Community 29 - "Community 29"
+### Community 27 - "Community 27"
 Cohesion: 0.29
 Nodes (2): BaseModel, SoftDeleteModel
 
-### Community 30 - "Community 30"
+### Community 28 - "Community 28"
 Cohesion: 0.38
 Nodes (6): derive_legacy_status_from_three(), derive_three_status_from_legacy(), 阶段A兼容:从旧 status 推导三字段。     约束:旧 status 本身混合了支付/配送/订单生命周期,因此只能尽力推断;     若业务侧已有更精确, 阶段A兼容:从三字段反推旧 status(用于旧前端/旧筛选参数兼容)。, ThreeStatus, _to_decimal()
 
-### Community 31 - "Community 31"
-Cohesion: 0.4
-Nodes (4): list_ethnics_for_select(), list_ethnics(), 民族码表 CRUD(全局 sys_ethnic), _row_to_dict()
-
-### Community 32 - "Community 32"
-Cohesion: 0.4
-Nodes (3): ApiRequestLogCRUD, delete(), get_multi_with_filters()
-
-### Community 33 - "Community 33"
+### Community 29 - "Community 29"
 Cohesion: 0.5
 Nodes (3): BaseSettings, Config, Settings
 
-### Community 34 - "Community 34"
+### Community 30 - "Community 30"
 Cohesion: 0.5
 Nodes (2): _auto_fill_create_dept_id(), ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID
 
-### Community 35 - "Community 35"
+### Community 31 - "Community 31"
 Cohesion: 0.67
 Nodes (2): get_menu_list(), 运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)
 
-### Community 36 - "Community 36"
+### Community 32 - "Community 32"
 Cohesion: 0.67
 Nodes (1): 转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。
 
-### Community 37 - "Community 37"
+### Community 33 - "Community 33"
 Cohesion: 1.0
 Nodes (1): 质检物料金额查看授权:专用角色与菜单权限常量。
 
-### Community 38 - "Community 38"
+### Community 34 - "Community 34"
 Cohesion: 1.0
 Nodes (1): 请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传
 
-### Community 39 - "Community 39"
+### Community 35 - "Community 35"
 Cohesion: 1.0
 Nodes (1): API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)
 
-### Community 42 - "Community 42"
+### Community 38 - "Community 38"
 Cohesion: 1.0
 Nodes (1): 发送订单状态通知                  Args:             openid: 用户openid             ord
 
-### Community 43 - "Community 43"
+### Community 39 - "Community 39"
 Cohesion: 1.0
 Nodes (1): 发送支付成功通知                  Args:             openid: 用户openid             ord
 
-### Community 44 - "Community 44"
+### Community 40 - "Community 40"
 Cohesion: 1.0
 Nodes (1): 发送配送通知                  Args:             openid: 配送员openid             task
 
-### Community 45 - "Community 45"
+### Community 41 - "Community 41"
 Cohesion: 1.0
 Nodes (1): 发送配送时效预警                  Args:             openid: 配送员openid             ta
 
-### Community 46 - "Community 46"
+### Community 42 - "Community 42"
 Cohesion: 1.0
 Nodes (1): 发送退款结果通知                  Args:             openid: 用户openid             ord
 
-### Community 47 - "Community 47"
+### Community 43 - "Community 43"
 Cohesion: 1.0
 Nodes (1): 发送催单通知给配送员                  Args:             deliver_openid: 配送员openid
 
 ## Knowledge Gaps
-- **171 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。` (+166 more)
+- **173 isolated node(s):** `刷新会话 TTL(refreshToken 续期;已登录用户每次 API 校验通过后滑动续期)`, `写入 refreshToken(用于 one-time 使用/吊销)`, `根据 token 获取当前用户(包含 Redis 会话校验;会话滑动续期)`, `登录凭据 RSA 解密(与前端 JSEncrypt PKCS#1 v1.5 兼容)。`, `加载 RSA 密钥对:优先环境变量,否则使用内置固定默认密钥。` (+168 more)
   These have ≤1 connection - possible missing edges or undocumented components.
-- **Thin community `Community 29`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
+- **Thin community `Community 27`** (8 nodes): `BaseModel`, `.to_dict()`, `get_active()`, `base.py`, `SoftDeleteModel`, `.restore()`, `.soft_delete()`, `__tablename__()`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 34`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
+- **Thin community `Community 30`** (4 nodes): `_auto_fill_create_dept_id()`, `get_db()`, `database.py`, `ORM before_insert 钩子:当 create_dept_id 为空时,自动从请求上下文填充当前用户部门 ID`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 35`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
+- **Thin community `Community 31`** (3 nodes): `get_menu_list()`, `menu.py`, `运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 36`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
+- **Thin community `Community 32`** (3 nodes): `add_months_ymd()`, `oa_confirmation_date.py`, `转正套件:入职日 + 试用期月数 → 建议转正日(YYYY-MM-DD)。`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 37`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
+- **Thin community `Community 33`** (2 nodes): `qc_material_amount_auth_constants.py`, `质检物料金额查看授权:专用角色与菜单权限常量。`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 38`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
+- **Thin community `Community 34`** (2 nodes): `context.py`, `请求级别的用户上下文变量  通过 contextvars 在请求处理链路中传递当前用户信息, 供 SQLAlchemy 事件监听器等底层机制使用,避免逐层手动传`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 39`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
+- **Thin community `Community 35`** (2 nodes): `__init__.py`, `API 路由包(骨架:公共鉴权 + 静态菜单 + 运营端占位)`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 42`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 38`** (1 nodes): `发送订单状态通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 43`** (1 nodes): `发送支付成功通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 39`** (1 nodes): `发送支付成功通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 44`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
+- **Thin community `Community 40`** (1 nodes): `发送配送通知                  Args:             openid: 配送员openid             task`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 45`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
+- **Thin community `Community 41`** (1 nodes): `发送配送时效预警                  Args:             openid: 配送员openid             ta`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 46`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
+- **Thin community `Community 42`** (1 nodes): `发送退款结果通知                  Args:             openid: 用户openid             ord`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
-- **Thin community `Community 47`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
+- **Thin community `Community 43`** (1 nodes): `发送催单通知给配送员                  Args:             deliver_openid: 配送员openid`
   Too small to be a meaningful cluster - may be noise or needs more connections extracted.
 
 ## Suggested Questions
 _Questions this graph is uniquely positioned to answer:_
 
-- **Why does `SysUser` connect `Community 2` to `Community 0`, `Community 1`, `Community 3`, `Community 4`, `Community 5`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 12`, `Community 21`, `Community 25`, `Community 26`?**
-  _High betweenness centrality (0.109) - this node is a cross-community bridge._
-- **Why does `success_response()` connect `Community 0` to `Community 1`, `Community 2`, `Community 35`, `Community 4`, `Community 7`, `Community 9`, `Community 10`, `Community 31`?**
-  _High betweenness centrality (0.043) - this node is a cross-community bridge._
-- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 11`, `Community 13`, `Community 14`, `Community 16`, `Community 19`, `Community 20`, `Community 25`, `Community 26`, `Community 27`, `Community 28`?**
-  _High betweenness centrality (0.030) - this node is a cross-community bridge._
-- **Are the 879 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
-  _`str` has 879 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 735 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
-  _`success_response()` has 735 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 608 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
-  _`error_response()` has 608 INFERRED edges - model-reasoned connections that need verification._
-- **Are the 596 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
-  _`SysUser` has 596 INFERRED edges - model-reasoned connections that need verification._
+- **Why does `SysUser` connect `Community 3` to `Community 0`, `Community 1`, `Community 2`, `Community 4`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 11`, `Community 12`, `Community 24`, `Community 25`?**
+  _High betweenness centrality (0.112) - this node is a cross-community bridge._
+- **Why does `success_response()` connect `Community 0` to `Community 2`, `Community 3`, `Community 6`, `Community 8`, `Community 9`, `Community 10`, `Community 31`?**
+  _High betweenness centrality (0.051) - this node is a cross-community bridge._
+- **Why does `generate_uuid()` connect `Community 1` to `Community 0`, `Community 2`, `Community 5`, `Community 6`, `Community 7`, `Community 8`, `Community 9`, `Community 10`, `Community 12`, `Community 13`, `Community 15`, `Community 17`, `Community 18`, `Community 20`, `Community 21`, `Community 24`, `Community 25`, `Community 26`?**
+  _High betweenness centrality (0.034) - this node is a cross-community bridge._
+- **Are the 883 inferred relationships involving `str` (e.g. with `unhandled_exception_handler()` and `get_current_user_by_token()`) actually correct?**
+  _`str` has 883 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 737 inferred relationships involving `success_response()` (e.g. with `list_storage_release_reviews()` and `get_storage_release_review_detail()`) actually correct?**
+  _`success_response()` has 737 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 610 inferred relationships involving `error_response()` (e.g. with `_json_error_response()` and `list_storage_release_reviews()`) actually correct?**
+  _`error_response()` has 610 INFERRED edges - model-reasoned connections that need verification._
+- **Are the 598 inferred relationships involving `SysUser` (e.g. with `数据权限过滤(V0.3:基于角色 data_scope 的查询级行级过滤)  data_scope 值:   1 = 全部数据   2 = 本部门数据   3` and `从 SysUser 读取用户的 dept_id。`) actually correct?**
+  _`SysUser` has 598 INFERRED edges - model-reasoned connections that need verification._

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 112 - 112
backend/graphify-out/graph.json


+ 9 - 6
backend/graphify-out/manifest.json

@@ -5,7 +5,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/auth.py": 1786415793.4635684,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/api_crypto.py": 1778578258.039314,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/login_crypto.py": 1782186069.0708613,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/core/config.py": 1786513288.6780405,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/core/config.py": 1786615315.0962946,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/wechat_pay.py": 1778578258.0461762,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/api_request_log_middleware.py": 1779882001.753267,
   "/Users/wuzilong/work/00code/zkxw/backend/app/core/avatar_url.py": 1780540753.5481932,
@@ -31,7 +31,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/material.py": 1782894693.6389399,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/production_material.py": 1782404882.9262497,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/qc_param.py": 1782379593.174925,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1786373798.8531444,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/models/models.py": 1786615057.801986,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/cleanroom_garment.py": 1781347080.6165943,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/cn_holiday.py": 1786373798.242758,
   "/Users/wuzilong/work/00code/zkxw/backend/app/models/__init__.py": 1781344070.5899634,
@@ -88,7 +88,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/post.py": 1785911428.7739174,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/sample_reception_check_result.py": 1779255909.8554008,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_material_stock_batch.py": 1782404955.550011,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/user.py": 1786415773.0246105,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/crud/user.py": 1786616084.8158407,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/production_work_order.py": 1785209206.5290542,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/material.py": 1781522283.6438622,
   "/Users/wuzilong/work/00code/zkxw/backend/app/crud/dict_type.py": 1779265621.5212462,
@@ -208,7 +208,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/research_donation.py": 1783920522.841086,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/ethnic.py": 1779266243.9511197,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_checkup.py": 1779935751.2658868,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/api/mini_auth.py": 1786354026.369999,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/api/mini_auth.py": 1786616100.716625,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/pilot_service.py": 1781599314.0221174,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/workflow.py": 1779266232.4568832,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/sample_sms_notification.py": 1780325524.907156,
@@ -218,7 +218,7 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/protocol_sample_collection.py": 1780457788.2093296,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/leave.py": 1779266168.8693042,
   "/Users/wuzilong/work/00code/zkxw/backend/app/api/clinical_payment.py": 1781750841.074568,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1786600896.5795553,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_engine.py": 1786615625.4962811,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_overtime_hours.py": 1786373766.5113294,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/workflow_callback.py": 1778727966.7277644,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/qc_work_order_fee_summary.py": 1783329884.6125586,
@@ -237,15 +237,18 @@
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_payment_installment.py": 1781432208.0765035,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_sample_list_summary.py": 1785207474.9871266,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_related_approval.py": 1786413868.1112978,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mp_message.py": 1786615349.0650096,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_leave_hours.py": 1785397787.0493405,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_resignation_kit_sync.py": 1785405020.285242,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_mp_auth.py": 1786615330.0733685,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_assignee.py": 1785835523.951382,
-  "/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_login_service.py": 1786354023.607799,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/services/wechat_login_service.py": 1786615952.6840453,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_receive_account.py": 1785390254.4121296,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_owner_fields.py": 1785401151.5200977,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_money_field.py": 1785380625.3526123,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/protocol_pay_mode_switch.py": 1785145536.322005,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_post_select.py": 1785394392.5716882,
+  "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_wechat_notify.py": 1786615701.8558133,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_confirmation_kit_sync.py": 1785403667.4270058,
   "/Users/wuzilong/work/00code/zkxw/backend/app/services/oa_formula.py": 1785145511.513383,
   "/Users/wuzilong/work/00code/zkxw/backend/app/ws/__init__.py": 1778578258.105995

+ 858 - 0
docs/superpowers/plans/2026-08-13-oa-wechat-service-notify.md

@@ -0,0 +1,858 @@
+# OA 服务号微信通知 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** OA 审批待办与抄送产生时,经已认证服务号模板消息通知对应用户;点击跳入小程序详情,并支持关注引导、mp 绑定与深链会话恢复。
+
+**Architecture:** 后端新增服务号消息服务 + OA 任务创建后异步推送;`sys_user` 存 `wx_mp_openid`/`wx_unionid`;小程序登录页跳转服务号主页(失败出二维码);登录后 web-view 网页授权绑定;深链页在无 JWT 时对已绑小程序 openid 静默换票,否则带 returnUrl 回登录页(复用现有选手机号)。
+
+**Tech Stack:** FastAPI、SQLAlchemy、MySQL 5.7、uni-app 微信小程序、微信服务号模板消息 / 网页授权 / `openOfficialAccountProfile`
+
+**Spec:** `docs/superpowers/specs/2026-08-13-oa-wechat-service-notify-design.md`
+
+---
+
+## File map
+
+| 路径 | 职责 |
+|------|------|
+| `docs/sql/alter-sys-user-wx-mp-openid.sql` | `wx_mp_openid` / `wx_unionid` / `wx_mp_bind_time` |
+| `backend/app/models/models.py` | `SysUser` 新列 |
+| `backend/app/core/config.py` | `WX_MP_*` 配置;`.env.example` 同步 |
+| `backend/app/services/wechat_mp_auth.py` | 服务号 access_token、oauth code 换 openid |
+| `backend/app/services/wechat_mp_message.py` | 审批/抄送模板发送 |
+| `backend/app/services/oa_wechat_notify.py` | 按 task_id 装载并异步发送 |
+| `backend/app/services/oa_engine.py` | 任务创建后收集 id;commit 后调度通知 |
+| `backend/app/crud/user.py` | `get_by_wx_mp_openid` |
+| `backend/app/api/mini_auth.py` | wechat-login 静默分支;mp-bind / mp-bind-status |
+| `backend/tests/test_wechat_mp_message.py` | 发送 payload 单测(mock http) |
+| `backend/tests/test_wechat_login_silent.py` | 已绑 openid 无 phoneCode 可登录 |
+| `backend/tests/test_oa_wechat_notify_hook.py` | 钩子按 task_type 选模板(mock) |
+| `mobile-h5/utils/config.js` | `MP_GH_USERNAME`、`MP_QRCODE_URL`、`MP_BIND_H5_PATH` |
+| `mobile-h5/utils/auth-session.js` | returnUrl 存取;`forceRelogin` 带 redirect |
+| `mobile-h5/utils/auth.js` | 静默微信登录、mp 绑定 API 封装 |
+| `mobile-h5/pages/login/index.vue` | 关注链接 + 登录成功回跳 |
+| `mobile-h5/pages/login/mp-qrcode-dialog` 或同页弹层 | 二维码兜底 |
+| `mobile-h5/pages/index/index.vue` | 「开启微信审批通知」入口 |
+| `mobile-h5/pages/oa/mp-bind/index.vue` | web-view 打开绑定 H5 |
+| `mobile-h5/pages/oa/webview/index.vue` | 深链会话恢复 |
+| `mobile-oa-h5` 绑定中间页 | 服务号 OAuth 回调 → 调后端 bind |
+| `mobile-h5/pages.json` | 注册 `oa/mp-bind` |
+| `mobile-h5/static/mp-qrcode.png` | 运营提供的服务号二维码(占位可先空文件说明) |
+
+**不修改:** web-admin OA 业务逻辑、企微、小程序订阅消息主通道、既有支付逻辑。
+
+---
+
+### Task 1: SQL + ORM 服务号字段
+
+**Files:**
+- Create: `docs/sql/alter-sys-user-wx-mp-openid.sql`
+- Modify: `backend/app/models/models.py`(`SysUser`,紧接 `wx_openid`)
+
+- [ ] **Step 1: 写 SQL**
+
+```sql
+-- docs/sql/alter-sys-user-wx-mp-openid.sql
+-- MySQL 5.7:唯一索引允许多个 NULL
+
+ALTER TABLE `sys_user`
+  ADD COLUMN `wx_mp_openid` varchar(64) DEFAULT NULL COMMENT '微信服务号 openid' AFTER `wx_openid`,
+  ADD COLUMN `wx_unionid` varchar(64) DEFAULT NULL COMMENT '微信开放平台 unionid' AFTER `wx_mp_openid`,
+  ADD COLUMN `wx_mp_bind_time` datetime DEFAULT NULL COMMENT '服务号绑定时间' AFTER `wx_unionid`;
+
+ALTER TABLE `sys_user`
+  ADD UNIQUE INDEX `uk_sys_user_wx_mp_openid` (`wx_mp_openid`);
+
+ALTER TABLE `sys_user`
+  ADD INDEX `idx_sys_user_wx_unionid` (`wx_unionid`);
+```
+
+- [ ] **Step 2: ORM**
+
+在 `SysUser.wx_openid` 后增加:
+
+```python
+    wx_mp_openid = Column(String(64), nullable=True, comment="微信服务号 openid")
+    wx_unionid = Column(String(64), nullable=True, comment="微信开放平台 unionid")
+    wx_mp_bind_time = Column(DateTime, nullable=True, comment="服务号绑定时间")
+```
+
+- [ ] **Step 3: 在本地/测试库执行 alter**
+
+Expected: 三列存在;多行 `wx_mp_openid` 为 NULL 可共存。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/sql/alter-sys-user-wx-mp-openid.sql backend/app/models/models.py
+git commit -m "$(cat <<'EOF'
+feat(db): add sys_user service-account WeChat bind columns
+
+EOF
+)"
+```
+
+---
+
+### Task 2: 配置 + 服务号 auth / 消息发送
+
+**Files:**
+- Modify: `backend/app/core/config.py`
+- Modify: `backend/.env.example`
+- Create: `backend/app/services/wechat_mp_auth.py`
+- Create: `backend/app/services/wechat_mp_message.py`
+- Create: `backend/tests/test_wechat_mp_message.py`
+
+- [ ] **Step 1: 写失败单测(mock 未配置)**
+
+```python
+# backend/tests/test_wechat_mp_message.py
+from unittest.mock import patch, MagicMock
+
+def test_send_approval_requires_mp_openid():
+    from app.services.wechat_mp_message import send_oa_approval_notify
+
+    r = send_oa_approval_notify(
+        mp_openid="",
+        pagepath="pages/oa/webview/index?instanceId=i1&taskId=t1&mode=approve",
+        title="请假",
+        applicant="张三",
+        node_name="主管审批",
+        time_text="2026-08-13 12:00",
+    )
+    assert r.get("success") is False
+```
+
+- [ ] **Step 2: Run 确认失败或 import 失败**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_wechat_mp_message.py::test_send_approval_requires_mp_openid -v
+```
+
+Expected: FAIL(模块不存在)或 assertion 相关。
+
+- [ ] **Step 3: config 增加字段**
+
+在 `Settings` 中(微信模板消息配置附近)增加:
+
+```python
+    # 微信服务号(OA 审批/抄送通知)
+    WX_MP_APPID: str = ""
+    WX_MP_SECRET: str = ""
+    WX_MP_TEMPLATE_APPROVAL: str = ""
+    WX_MP_TEMPLATE_CC: str = ""
+    # 模板 data 字段名(与微信后台申请结果一致,可改 env)
+    WX_MP_TPL_FIELD_TITLE: str = "thing1"
+    WX_MP_TPL_FIELD_APPLICANT: str = "thing2"
+    WX_MP_TPL_FIELD_NODE: str = "thing3"
+    WX_MP_TPL_FIELD_TIME: str = "time4"
+```
+
+`.env.example` 同步注释说明。
+
+- [ ] **Step 4: 实现 `wechat_mp_auth.py`**
+
+```python
+"""微信服务号:access_token、oauth code 换 openid。"""
+from __future__ import annotations
+
+import logging
+from datetime import datetime, timedelta
+from typing import Any, Dict, Optional
+
+import httpx
+
+from app.core.config import settings
+
+logger = logging.getLogger(__name__)
+
+_token: Optional[str] = None
+_token_expire: Optional[datetime] = None
+
+
+class WechatMpAuthError(Exception):
+    pass
+
+
+def _require_mp_credentials() -> tuple:
+    appid = (settings.WX_MP_APPID or "").strip()
+    secret = (settings.WX_MP_SECRET or "").strip()
+    if not appid or not secret:
+        raise WechatMpAuthError("未配置服务号凭证(WX_MP_APPID / WX_MP_SECRET)")
+    return appid, secret
+
+
+def get_mp_access_token() -> str:
+    global _token, _token_expire
+    if _token and _token_expire and datetime.now() < _token_expire:
+        return _token
+    appid, secret = _require_mp_credentials()
+    with httpx.Client(timeout=15.0) as client:
+        r = client.get(
+            "https://api.weixin.qq.com/cgi-bin/token",
+            params={
+                "grant_type": "client_credential",
+                "appid": appid,
+                "secret": secret,
+            },
+        )
+        r.raise_for_status()
+        data = r.json()
+    token = data.get("access_token")
+    if not token:
+        logger.warning("mp access_token failed: %s", data)
+        raise WechatMpAuthError("获取服务号 access_token 失败")
+    _token = token
+    _token_expire = datetime.now() + timedelta(seconds=int(data.get("expires_in", 7200)) - 300)
+    return token
+
+
+def oauth_code_to_openid(code: str) -> Dict[str, Any]:
+    """snsapi_base:code → openid / unionid。"""
+    c = (code or "").strip()
+    if not c:
+        raise WechatMpAuthError("code 不能为空")
+    appid, secret = _require_mp_credentials()
+    with httpx.Client(timeout=15.0) as client:
+        r = client.get(
+            "https://api.weixin.qq.com/sns/oauth2/access_token",
+            params={
+                "appid": appid,
+                "secret": secret,
+                "code": c,
+                "grant_type": "authorization_code",
+            },
+        )
+        r.raise_for_status()
+        data = r.json()
+    if data.get("errcode"):
+        raise WechatMpAuthError(data.get("errmsg") or "服务号授权失败")
+    openid = data.get("openid")
+    if not openid:
+        raise WechatMpAuthError("服务号未返回 openid")
+    return {"openid": openid, "unionid": data.get("unionid")}
+```
+
+- [ ] **Step 5: 实现 `wechat_mp_message.py`**
+
+核心:`_send_template` 调 `cgi-bin/message/template/send`;`miniprogram.appid` 用 `settings.WXPAY_MINI_APPID`;`send_oa_approval_notify` / `send_oa_cc_notify` 分别用 `WX_MP_TEMPLATE_APPROVAL` / `WX_MP_TEMPLATE_CC`;空 `mp_openid` 直接 `{"success": False, "error": "missing_mp_openid"}`;微信字段值做长度截断(thing 类约 20 汉字内,按微信规则保守截断)。
+
+```python
+def send_oa_approval_notify(*, mp_openid: str, pagepath: str, title: str, applicant: str, node_name: str, time_text: str) -> dict:
+    ...
+
+def send_oa_cc_notify(*, mp_openid: str, pagepath: str, title: str, applicant: str, node_name: str, time_text: str) -> dict:
+    ...
+```
+
+- [ ] **Step 6: 补全单测(mock httpx / get_mp_access_token)并跑通**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_wechat_mp_message.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add backend/app/core/config.py backend/.env.example \
+  backend/app/services/wechat_mp_auth.py backend/app/services/wechat_mp_message.py \
+  backend/tests/test_wechat_mp_message.py
+git commit -m "$(cat <<'EOF'
+feat(backend): add WeChat service-account template message sender
+
+EOF
+)"
+```
+
+---
+
+### Task 3: OA 异步通知服务 + 引擎钩子
+
+**Files:**
+- Create: `backend/app/services/oa_wechat_notify.py`
+- Modify: `backend/app/services/oa_engine.py`
+- Create: `backend/tests/test_oa_wechat_notify_hook.py`
+- Modify: `backend/app/crud/user.py`(如需 `get_by_wx_mp_openid`)
+
+- [ ] **Step 1: 实现 `oa_wechat_notify.py`**
+
+```python
+"""OA 任务创建后服务号通知(commit 后异步)。"""
+from __future__ import annotations
+
+import logging
+import threading
+from datetime import datetime
+from typing import Iterable, List, Optional
+
+from app.core.database import SessionLocal
+from app.models.models import OaInstance, OaTask, SysUser
+from app.services.wechat_mp_message import send_oa_approval_notify, send_oa_cc_notify
+
+logger = logging.getLogger(__name__)
+
+
+def build_oa_detail_pagepath(*, instance_id: str, task_id: str, mode: str) -> str:
+    # 无前导 /
+    return (
+        f"pages/oa/webview/index?instanceId={instance_id}"
+        f"&taskId={task_id}&mode={mode}"
+    )
+
+
+def _send_one(db, task: OaTask) -> None:
+    uid = (task.assignee_user_id or "").strip()
+    if not uid:
+        return
+    user = db.query(SysUser).filter(SysUser.id == uid, SysUser.del_flag == 0).first()
+    mp = (getattr(user, "wx_mp_openid", None) or "").strip() if user else ""
+    if not mp:
+        logger.info("oa wx notify skip: no mp_openid user=%s task=%s", uid, task.id)
+        return
+    inst = (
+        db.query(OaInstance)
+        .filter(OaInstance.id == task.instance_id, OaInstance.del_flag == 0)
+        .first()
+    )
+    title = (inst.title if inst else "") or (task.node_name or "审批")
+    applicant = ""
+    if inst and inst.initiator_user_id:
+        ini = db.query(SysUser).filter(SysUser.id == inst.initiator_user_id).first()
+        applicant = (ini.nick_name if ini else "") or ""
+    time_text = datetime.now().strftime("%Y-%m-%d %H:%M")
+    node_name = task.node_name or ""
+    if task.task_type == "approval" and (task.status or "") == "pending":
+        pagepath = build_oa_detail_pagepath(
+            instance_id=task.instance_id, task_id=task.id, mode="approve"
+        )
+        send_oa_approval_notify(
+            mp_openid=mp,
+            pagepath=pagepath,
+            title=title,
+            applicant=applicant,
+            node_name=node_name,
+            time_text=time_text,
+        )
+    elif task.task_type == "cc":
+        pagepath = build_oa_detail_pagepath(
+            instance_id=task.instance_id, task_id=task.id, mode="view"
+        )
+        send_oa_cc_notify(
+            mp_openid=mp,
+            pagepath=pagepath,
+            title=title,
+            applicant=applicant,
+            node_name=node_name,
+            time_text=time_text,
+        )
+
+
+def notify_oa_tasks_sync(task_ids: Iterable[str]) -> None:
+    ids = [str(x).strip() for x in task_ids if str(x).strip()]
+    if not ids:
+        return
+    db = SessionLocal()
+    try:
+        tasks = (
+            db.query(OaTask)
+            .filter(OaTask.id.in_(ids), OaTask.del_flag == 0)
+            .all()
+        )
+        for t in tasks:
+            try:
+                _send_one(db, t)
+            except Exception:
+                logger.exception("oa wx notify failed task=%s", t.id)
+    finally:
+        db.close()
+
+
+def schedule_oa_task_notifies(task_ids: List[str]) -> None:
+    ids = [str(x).strip() for x in (task_ids or []) if str(x).strip()]
+    if not ids:
+        return
+    threading.Thread(
+        target=notify_oa_tasks_sync, args=(ids,), daemon=True, name="oa-wx-notify"
+    ).start()
+```
+
+- [ ] **Step 2: 改造 `advance_from` 返回新建 task id 列表**
+
+在 `advance_from` 内:`created_ids: List[str] = []`;每次 `_create_tasks_for_node` 后 `created_ids.extend([t.id for t in tasks])`;函数末尾 `return created_ids`(原先无返回值,改为 `List[str]`)。
+
+同步改测试中忽略返回值的调用(仍可丢弃返回值)。
+
+- [ ] **Step 3: 在 `start_instance` 的 `db.commit()` 之后调度**
+
+```python
+    created = advance_from(...)  # 已改返回值
+    db.commit()
+    from app.services.oa_wechat_notify import schedule_oa_task_notifies
+    schedule_oa_task_notifies(created or [])
+    db.refresh(inst)
+    return get_instance_detail(...)
+```
+
+对 `approve` / 会调用 `advance_from` 并 `commit` 的路径同样:保存返回的 ids,**commit 之后**再 `schedule_oa_task_notifies`。
+
+转交若直接 `OaTask(...)` 新建 pending 任务:在对应 `db.commit()` 后把新 task id 列入 `schedule_oa_task_notifies`。
+
+- [ ] **Step 4: 单测钩子选择(mock send)**
+
+```python
+# backend/tests/test_oa_wechat_notify_hook.py
+from unittest.mock import patch
+
+def test_build_pagepath_no_leading_slash():
+    from app.services.oa_wechat_notify import build_oa_detail_pagepath
+    p = build_oa_detail_pagepath(instance_id="i1", task_id="t1", mode="approve")
+    assert p.startswith("pages/")
+    assert "mode=approve" in p
+```
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_oa_wechat_notify_hook.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/oa_wechat_notify.py backend/app/services/oa_engine.py \
+  backend/tests/test_oa_wechat_notify_hook.py
+git commit -m "$(cat <<'EOF'
+feat(oa): schedule WeChat MP notifies after approval/cc tasks create
+
+EOF
+)"
+```
+
+---
+
+### Task 4: wechat-login 静默换票(已绑 openid)
+
+**Files:**
+- Modify: `backend/app/services/wechat_login_service.py`
+- Modify: `backend/app/api/mini_auth.py`
+- Create: `backend/tests/test_wechat_login_silent.py`
+
+- [ ] **Step 1: 增加 `resolve_wechat_login_by_openid_only`**
+
+```python
+def resolve_wechat_login_by_openid_only(
+    db: Session, *, openid: str
+) -> Tuple[Optional[SysUser], Optional[str], Optional[int]]:
+    oid = (openid or "").strip()
+    if not oid:
+        return None, "微信未返回 openid", 400
+    bound = user_crud.get_by_wx_openid(db, oid)
+    if not bound:
+        return None, "请授权手机号登录,或使用账号密码登录", 400
+    if getattr(bound, "status", "1") != "1":
+        return None, "账号已停用", 403
+    return bound, None, None
+```
+
+- [ ] **Step 2: 修改 `wechat_login` 路由**
+
+逻辑:
+
+1. `code2session(login_code)`  
+2. 若无 `phoneCode`:走 `resolve_wechat_login_by_openid_only`;失败则返回现有文案「请授权手机号…」  
+3. 若有 `phoneCode`:保持现有 `resolve_wechat_login_user`;成功时可把 `sess.get("unionid")` 写入 `user.wx_unionid`(若空则填)  
+4. `issue_tokens_for_user`
+
+- [ ] **Step 3: 单测**
+
+用 mock `user_crud.get_by_wx_openid` 返回用户,断言无 phone 也能 resolve;未绑定返回 400 文案。
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_wechat_login_silent.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add backend/app/services/wechat_login_service.py backend/app/api/mini_auth.py \
+  backend/tests/test_wechat_login_silent.py
+git commit -m "$(cat <<'EOF'
+feat(mini-auth): allow silent wechat login when openid already bound
+
+EOF
+)"
+```
+
+---
+
+### Task 5: mp-bind / mp-bind-status API
+
+**Files:**
+- Modify: `backend/app/crud/user.py`
+- Modify: `backend/app/api/mini_auth.py`
+- Create: `backend/tests/test_mp_bind.py`
+
+- [ ] **Step 1: CRUD**
+
+```python
+def get_by_wx_mp_openid(self, db: Session, wx_mp_openid: str) -> Optional[SysUser]:
+    oid = (wx_mp_openid or "").strip()
+    if not oid:
+        return None
+    return (
+        db.query(SysUser)
+        .filter(SysUser.wx_mp_openid == oid, SysUser.del_flag == 0)
+        .first()
+    )
+```
+
+- [ ] **Step 2: 路由**
+
+```python
+@router.get("/mp-bind-status")
+async def mp_bind_status(request: Request, db: Session = Depends(get_db)):
+    user_id = await _resolve_user_id(request, db)
+    user = user_crud.get(db, user_id)
+    bound = bool(user and (getattr(user, "wx_mp_openid", None) or "").strip())
+    return success_response({"bound": bound})
+
+@router.post("/mp-bind")
+async def mp_bind(request: Request, body: dict, db: Session = Depends(get_db)):
+    user_id = await _resolve_user_id(request, db)
+    code = (body.get("code") or "").strip()
+    try:
+        info = oauth_code_to_openid(code)
+    except WechatMpAuthError as exc:
+        return error_response(400, str(exc))
+    mp_openid = info["openid"]
+    other = user_crud.get_by_wx_mp_openid(db, mp_openid)
+    if other and other.id != user_id:
+        return error_response(409, "该微信服务号已绑定其他账号")
+    user = user_crud.get(db, user_id)
+    if not user or user.del_flag != 0:
+        return error_response(401, "用户不存在")
+    user.wx_mp_openid = mp_openid
+    if info.get("unionid"):
+        user.wx_unionid = info["unionid"]
+    user.wx_mp_bind_time = datetime.now()
+    user.update_time = datetime.now()
+    db.add(user)
+    db.commit()
+    return success_response({"bound": True})
+```
+
+- [ ] **Step 3: 单测冲突与成功路径(mock oauth)**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_mp_bind.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add backend/app/crud/user.py backend/app/api/mini_auth.py backend/tests/test_mp_bind.py
+git commit -m "$(cat <<'EOF'
+feat(mini-auth): add WeChat service-account bind APIs
+
+EOF
+)"
+```
+
+---
+
+### Task 6: mobile-oa-h5 绑定中间页
+
+**Files:**
+- Create: `mobile-oa-h5/src/oa/mp-bind/`(路由页,如 `MpBindPage.vue` + router)
+- 确保该路径在服务号「网页授权回调域名」与小程序业务域名白名单内(运维配置,计划中写明检查项)
+
+- [ ] **Step 1: 页面行为**
+
+1. Query:`token`(小程序传入)、可选 `redirect`。  
+2. 若 URL 无微信 `code`:跳转  
+   `https://open.weixin.qq.com/connect/oauth2/authorize?appid={WX_MP_APPID}&redirect_uri={encodeURIComponent(当前页去参稳定 URL)}&response_type=code&scope=snsapi_base&state=mpbind#wechat_redirect`  
+   (`appid` 由构建时 env `VITE_WX_MP_APPID` 注入,勿把 secret 放前端。)  
+3. 若有 `code`:`POST /api/mini/auth/mp-bind`,Header `Authorization: Bearer {token}`,body `{ code }`。  
+4. 成功:展示「已开启审批通知」;可选 `wx.miniProgram.navigateBack`。  
+5. 失败:展示错误文案。
+
+- [ ] **Step 2: 本地用已登录 token + 假 code 看错误态;真机联调需真实服务号。**
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add mobile-oa-h5/src/oa/mp-bind mobile-oa-h5/...router...
+git commit -m "$(cat <<'EOF'
+feat(mobile-oa-h5): add service-account OAuth bind page for OA notify
+
+EOF
+)"
+```
+
+---
+
+### Task 7: mobile-h5 登录页关注入口 + 配置
+
+**Files:**
+- Modify: `mobile-h5/utils/config.js`
+- Modify: `mobile-h5/pages/login/index.vue`
+- Modify: `mobile-h5/styles/components/login.scss`
+- Create: `mobile-h5/static/mp-qrcode.png`(运营图;无图时先放说明 README 或占位)
+
+- [ ] **Step 1: config**
+
+```javascript
+/** 服务号原始 ID(gh_…),用于 openOfficialAccountProfile */
+export const MP_GH_USERNAME = '' // 上线前填写
+/** 关注二维码图片(兜底) */
+export const MP_QRCODE_URL = '/static/mp-qrcode.png'
+/** 绑定 H5 路径(相对 OA_H5_ORIGIN) */
+export const MP_BIND_H5_PATH = '/oa-mp-bind/'
+```
+
+- [ ] **Step 2: 登录卡片下增加链接**
+
+文案:`关注服务号,接收审批通知`  
+点击 `onFollowMp`:
+
+```javascript
+onFollowMp() {
+  const username = (MP_GH_USERNAME || '').trim()
+  const openProfile =
+    typeof wx !== 'undefined' && typeof wx.openOfficialAccountProfile === 'function'
+  if (openProfile && username) {
+    wx.openOfficialAccountProfile({
+      username,
+      fail: () => {
+        this.showMpQrcode = true
+      }
+    })
+    return
+  }
+  this.showMpQrcode = true
+}
+```
+
+弹层:居中二维码图 +「长按识别关注」+ 关闭。
+
+- [ ] **Step 3: 真机点链接:同主体应进公众号主页;故意错 username 应出二维码。**
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add mobile-h5/utils/config.js mobile-h5/pages/login/index.vue \
+  mobile-h5/styles/components/login.scss mobile-h5/static/mp-qrcode.png
+git commit -m "$(cat <<'EOF'
+feat(mobile-h5): add follow service-account entry on login page
+
+EOF
+)"
+```
+
+---
+
+### Task 8: 工作台绑定入口 + mp-bind web-view 页
+
+**Files:**
+- Create: `mobile-h5/pages/oa/mp-bind/index.vue`
+- Modify: `mobile-h5/pages.json`
+- Modify: `mobile-h5/pages/index/index.vue`
+- Modify: `mobile-h5/utils/auth.js`(`fetchMpBindStatus`)
+
+- [ ] **Step 1: `pages/oa/mp-bind/index.vue`**
+
+- `onLoad`:无 token → `forceRelogin`;有则  
+  `src = OA_H5_ORIGIN + MP_BIND_H5_PATH + '?token=' + encodeURIComponent(token)`  
+- `<web-view :src="src" />`
+
+- [ ] **Step 2: pages.json 注册**
+
+```json
+{
+  "path": "pages/oa/mp-bind/index",
+  "style": { "navigationBarTitleText": "微信审批通知" }
+}
+```
+
+- [ ] **Step 3: 首页 meta 区加次要入口**
+
+文案:`开启微信审批通知`(已绑定可显示「已开启」灰色不可点或点击提示已绑定)。  
+`onShow` 调 `GET /mini/auth/mp-bind-status`(需登录)。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add mobile-h5/pages/oa/mp-bind/index.vue mobile-h5/pages.json \
+  mobile-h5/pages/index/index.vue mobile-h5/utils/auth.js
+git commit -m "$(cat <<'EOF'
+feat(mobile-h5): add OA WeChat notify bind entry and web-view page
+
+EOF
+)"
+```
+
+---
+
+### Task 9: 深链会话恢复 + 登录 returnUrl
+
+**Files:**
+- Modify: `mobile-h5/utils/auth-session.js`
+- Modify: `mobile-h5/utils/auth.js`
+- Modify: `mobile-h5/pages/oa/webview/index.vue`
+- Modify: `mobile-h5/pages/login/index.vue`
+
+- [ ] **Step 1: returnUrl 工具**
+
+```javascript
+const RETURN_URL_KEY = 'zkxw_mobile_return_url'
+
+export function setReturnUrl(url) {
+  try {
+    uni.setStorageSync(RETURN_URL_KEY, url || '')
+  } catch (e) {}
+}
+
+export function takeReturnUrl() {
+  try {
+    const u = uni.getStorageSync(RETURN_URL_KEY) || ''
+    uni.removeStorageSync(RETURN_URL_KEY)
+    return u
+  } catch (e) {
+    return ''
+  }
+}
+
+export function forceRelogin(message, returnUrl) {
+  // 在清会话前:若传入 returnUrl 则 setReturnUrl
+  // reLaunch 仍到 /pages/login/index
+  ...
+}
+```
+
+- [ ] **Step 2: `silentWechatLogin` in auth.js**
+
+```javascript
+export async function silentWechatLogin() {
+  const loginRes = await new Promise((resolve, reject) => {
+    uni.login({ provider: 'weixin', success: resolve, fail: reject })
+  })
+  const loginCode = loginRes.code
+  const data = await post('/mini/auth/wechat-login', { loginCode })
+  if (!data || !data.token) throw new Error('静默登录失败')
+  saveSession({ token: data.token, refreshToken: data.refreshToken, userInfo: data.userInfo })
+  return data
+}
+```
+
+- [ ] **Step 3: webview `onLoad`**
+
+伪代码顺序:
+
+1. 解析 query,保存到 data。  
+2. 若 `getToken()` 有值 → 现有逻辑拼 H5。  
+3. 否则 `try { await silentWechatLogin() }` 成功则拼 H5。  
+4. 失败则 `forceRelogin('请先登录', '/pages/oa/webview/index?' + queryString)`。
+
+- [ ] **Step 4: 登录成功后**
+
+密码登录 / 微信登录成功分支末尾:
+
+```javascript
+const back = takeReturnUrl()
+if (back) {
+  uni.reLaunch({ url: back.startsWith('/') ? back : '/' + back })
+  return
+}
+// 原跳转首页
+```
+
+- [ ] **Step 5: 手测**
+
+- 已登录点模板(开发者工具编译模式可模拟 path)进详情。  
+- 清 storage 后,已绑 openid 账号应静默进。  
+- 未绑应回登录,登录后回详情。
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add mobile-h5/utils/auth-session.js mobile-h5/utils/auth.js \
+  mobile-h5/pages/oa/webview/index.vue mobile-h5/pages/login/index.vue
+git commit -m "$(cat <<'EOF'
+feat(mobile-h5): restore session on OA notify deep link with returnUrl
+
+EOF
+)"
+```
+
+---
+
+### Task 10: 联调清单、图谱、文档收尾
+
+**Files:**
+- 可选:在 design spec 末尾加「实现完成检查表」勾选状态(或本 plan 勾选)
+- Run: `./scripts/update-graph.sh backend`(及若动了 frontend 图谱约定则 frontend;本仓库 mobile-h5 可能不在 graphify 范围,仅 backend)
+
+- [ ] **Step 1: 环境变量清单(交给运维填写)**
+
+| 变量 | 位置 |
+|------|------|
+| `WX_MP_APPID` / `WX_MP_SECRET` | backend `.env` |
+| `WX_MP_TEMPLATE_APPROVAL` / `WX_MP_TEMPLATE_CC` | backend |
+| `WX_MP_TPL_FIELD_*` | 与微信模板字段对齐后填写 |
+| `VITE_WX_MP_APPID` | mobile-oa-h5 |
+| `MP_GH_USERNAME` | mobile-h5 `config.js` |
+| 网页授权回调域名、小程序关联服务号、业务域名 | 微信后台 |
+
+- [ ] **Step 2: 真机验收(对照 spec §8)**
+
+1. 审批推送 + 点击可批  
+2. 抄送推送文案不同 + 查看态  
+3. 未绑定 skip  
+4. 静默登录  
+5. 选手机号登录回跳  
+6. 关注入口  
+7. 并签多人各一条  
+
+- [ ] **Step 3: 更新图谱**
+
+```bash
+./scripts/update-graph.sh backend
+```
+
+- [ ] **Step 4: Commit**(若有 graph / 文档变更)
+
+```bash
+git add backend/graphify-out docs/superpowers/specs/2026-08-13-oa-wechat-service-notify-design.md
+git commit -m "$(cat <<'EOF'
+chore: update graph after OA WeChat notify integration
+
+EOF
+)"
+```
+
+---
+
+## Spec coverage(自检)
+
+| Spec 要求 | Task |
+|-----------|------|
+| 服务号双模板审批/抄送 | 2、3 |
+| `wx_mp_openid` / unionid | 1、5 |
+| OA 创建任务后异步通知、失败不阻断 | 3 |
+| pagepath 直达 webview + mode | 3、9 |
+| 登录页关注 + Profile / 二维码 | 7 |
+| 登录后网页授权绑定 | 5、6、8 |
+| 静默登录 + returnUrl + 复用选号 | 4、9 |
+| 一人一账号;多号走现有登录 | 4、9 |
+| P0 可用手工 mp_openid 测推送 | 1–3 完成后即可 |
+
+## Placeholder scan
+
+无 TBD 实现步骤;模板字段名用可配置 env,联调时填正式值。

+ 264 - 0
docs/superpowers/specs/2026-08-13-oa-wechat-service-notify-design.md

@@ -0,0 +1,264 @@
+# OA 审批/抄送 · 服务号微信通知设计
+
+> 日期:2026-08-13  
+> 工程:`backend` + `mobile-h5`(微信小程序);`mobile-oa-h5` / `web-admin` 首期基本不动  
+> 相关:`docs/superpowers/specs/2026-08-10-mobile-h5-wechat-login-design.md`、OA 待办中心与 `pages/oa/webview` 深链
+
+---
+
+## 1. 背景与目标
+
+### 1.1 现状
+
+- OA 审批引擎已可产生审批待办(`approval` + `pending`)与抄送任务(`cc`)。
+- `mobile-h5` 已具备待办/抄送列表与详情 web-view(`instanceId` + `taskId`)。
+- 用户表已有小程序 `wx_openid`;微信授权登录已支持微信侧多手机号选择(`getPhoneNumber` 系统面板)。
+- 后端存在 `WechatMessageService` 雏形,但配置与调用偏向小程序 appid,**不能直接作为服务号推送实现**。
+- **无**服务号 openid 字段、无 OA 待办创建后的推送钩子、无服务号关注/绑定引导。
+
+### 1.2 目标
+
+1. 流程到达审批人或产生抄送时,向对应用户发送**已认证服务号模板消息**。
+2. 用户点击消息后进入小程序**对应审批/抄送详情**,在已登录或可恢复会话前提下完成办理或查看。
+3. 登录页提供「关注服务号」入口;登录后完成服务号身份与系统账号绑定。
+4. 审批与抄送使用**两套模板**(文案区分);推送失败不阻断 OA 业务。
+
+### 1.3 非目标(首期)
+
+- 非 OA 的其它工作流(如独立 workflow/请假引擎)推送。
+- 企业微信应用消息、短信、邮件作为主通道。
+- 以小程序订阅消息作为主推送通道(可作远期补充)。
+- web-admin 发送监控台、运营群发。
+- 改造 OA 审批业务规则本身。
+
+---
+
+## 2. 已确认决策
+
+| 决策点 | 结论 |
+|--------|------|
+| 推送通道 | 已认证**服务号**模板消息;小程序与服务号可关联 |
+| 身份绑定 | 员工关注服务号 + 登录后显式绑定,写入服务号 openid / unionid |
+| 推送范围 | OA **审批待办 + 抄送**;各用一套模板文案区分 |
+| 点击落地 | 直达 `pages/oa/webview`(`instanceId` + `taskId`);审批 `mode=approve`,抄送 `mode=view` |
+| 会话恢复 | 已绑小程序 openid → 深链可静默换票;否则走现有登录页 + returnUrl 回跳 |
+| 多手机号 | 系统侧一人一账号一手机号;微信侧多号由**现有登录页微信授权**处理,深链不重复造选号 UI |
+| 登录页关注 | 次要链接「关注服务号,接收审批通知」→ 优先 `wx.openOfficialAccountProfile`;失败/低版本 → 二维码弹层 |
+| 未绑定推送 | 跳过发送并记日志,**不阻断** OA 提交/流转 |
+
+---
+
+## 3. 架构与数据流
+
+```
+OA 引擎 _create_tasks_for_node
+  ├─ approval(pending) → 异步:审批模板 → 服务号 API
+  └─ cc               → 异步:抄送模板 → 服务号 API
+         │
+         ▼
+  查 sys_user.wx_mp_openid
+         │ 无则 skip
+         ▼
+  template/send + miniprogram.pagepath
+         │
+         ▼
+  用户点击 → 打开小程序 webview 详情
+         │
+         ├─ 本地 JWT 有效 → 直接办/看
+         ├─ 无 JWT + 小程序 openid 已绑定 → 静默 wechat-login(仅 loginCode) → 详情
+         └─ 否则 → 登录页(已有选手机号)→ returnUrl 回详情
+```
+
+### 3.1 微信账号关系
+
+| 标识 | 用途 |
+|------|------|
+| 小程序 `wx_openid` | 已有;静默恢复登录、业务鉴权侧微信身份 |
+| 服务号 `wx_mp_openid` | 模板消息 `touser` |
+| `wx_unionid` | 开放平台下打通两端;绑定与排查用 |
+
+前提:服务号与小程序绑定同一微信开放平台;服务号与小程序关联;跳转主页需**同主体或开放平台主体关联**(`openOfficialAccountProfile`)。
+
+---
+
+## 4. 数据模型
+
+在 `sys_user` 增加(SQL 落 `docs/sql/`,同步 ORM):
+
+| 列名 | 类型 | 说明 |
+|------|------|------|
+| `wx_mp_openid` | `VARCHAR(64) NULL` | 服务号下 openid |
+| `wx_unionid` | `VARCHAR(64) NULL` | 开放平台 unionid |
+| `wx_mp_bind_time` | `DATETIME NULL` | 服务号绑定时间(可选,便于审计) |
+
+约束建议:
+
+- `wx_mp_openid` 非空唯一(应用层保证一微信号服务号身份不绑两人)。
+- `wx_unionid` 可索引,非强制唯一(历史脏数据时以 openid 为准)。
+
+可选(实现阶段二选一,设计允许延后):
+
+- 轻量发送日志表 `biz_wx_notify_log`(user_id、task_id、template_type、status、errcode、create_time),便于客诉排查;首期也可用应用日志,表可后补。
+
+---
+
+## 5. 后端设计
+
+### 5.1 配置(`.env`,禁止写入前端源码)
+
+| 变量 | 说明 |
+|------|------|
+| `WX_MP_APPID` | 服务号 AppID |
+| `WX_MP_SECRET` | 服务号 AppSecret |
+| `WX_MP_TEMPLATE_APPROVAL` | 审批待办模板 ID |
+| `WX_MP_TEMPLATE_CC` | 抄送知会模板 ID |
+| `WX_MP_GH_USERNAME` | 服务号原始 ID(`gh_…`),供小程序跳转主页;也可仅配前端 |
+| 已有 `WXPAY_MINI_APPID` / `WXPAY_SECRET` | 小程序;模板消息 `miniprogram.appid` 使用小程序 AppID |
+
+### 5.2 服务号消息发送
+
+新建(或重构)`WechatMpMessageService`(与现有小程序向 `WechatMessageService` 分离,避免 appid/token 混用):
+
+- 使用**服务号** appid/secret 取 `access_token`。
+- 调用 `POST /cgi-bin/message/template/send`。
+- `touser` = `wx_mp_openid`。
+- `miniprogram`: `{ appid: 小程序AppID, pagepath: "pages/oa/webview/index?instanceId=…&taskId=…&mode=…" }`  
+  - `pagepath` **无**前导 `/`。  
+  - 审批:`mode=approve`;抄送:`mode=view`。
+- 可选备用 `url`(H5 说明页);与 `miniprogram` 同传时优先小程序。
+
+模板字段(以微信后台最终审核字段名为准,实现时做映射配置):建议包含审批类型/标题、申请人、时间、摘要或节点名;审批模板标题语义为「待您审批」,抄送为「抄送知会」。
+
+### 5.3 绑定 API
+
+- `POST /api/mini/auth/mp-bind`(需登录):小程序 web-view 中间页走服务号网页授权 `snsapi_base`,将 `code` 交给后端,换取 `wx_mp_openid`(及 unionid),写入当前用户。
+- `GET /api/mini/auth/mp-bind-status`:是否已绑定(供个人中心展示)。
+
+绑定冲突:该 `wx_mp_openid` 已绑其他用户 → 返回明确错误。
+
+### 5.4 静默登录放宽
+
+调整 `POST /api/mini/auth/wechat-login`:
+
+- **若** `loginCode` 换得的小程序 openid 已绑定唯一启用用户,且**未传** `phoneCode` → 允许直接发 JWT(深链会话恢复)。
+- **若**未绑定 → 仍要求手机号授权(或引导账号密码),行为与现网登录页一致。
+- 已绑定但调用方仍带 `phoneCode`:须校验手机号与账号 `phone_no` 一致(保持安全)。
+
+### 5.5 OA 推送钩子
+
+在 `oa_engine._create_tasks_for_node`(或统一的任务创建出口)于任务入库后:
+
+1. 对每个新建任务触发异步通知(线程池 / 后台 task,**不**阻塞请求事务提交;宜在 commit 后发送)。
+2. `task_type == "approval"` 且 `status == "pending"` → 审批模板。
+3. `task_type == "cc"` → 抄送模板。
+4. 查 assignee 的 `wx_mp_openid`;空则 debug/info 日志后返回。
+5. 发送失败:重试 1~2 次(短间隔);仍失败记 error 日志;不回滚 OA。
+
+同一用户同一节点多人并签/或签:每人一条任务则每人一条通知(与任务一一对应)。
+
+---
+
+## 6. 小程序(`mobile-h5`)设计
+
+### 6.1 登录页关注入口
+
+- 位置:登录卡片下方次要链接文案:「关注服务号,接收审批通知」。
+- 点击:
+  1. 调用 `wx.openOfficialAccountProfile({ username: gh_… })`(基础库 ≥ 3.7.10)。
+  2. `fail` 或基础库不足 → 弹出服务号二维码(长按识别);二维码资源可静态配置或远端 URL。
+- 本入口只解决「关注」,不在此完成账号绑定。
+
+### 6.2 登录后绑定入口
+
+- 个人中心(或工作台设置)「开启微信审批通知」:检查绑定状态;未绑定则走绑定流程(web-view 授权或扫码说明)。
+- 首次进入 OA 待办且未绑定:可弱提示一次(非强制)。
+
+### 6.3 深链与会话
+
+- 模板 `pagepath` 打开 `pages/oa/webview/index`。
+- 进入时:
+  1. 有有效本地 token → 按现逻辑拼 H5 URL(含 token)打开详情。
+  2. 无 token → 尝试静默 `wx.login` + `wechat-login`(仅 loginCode);成功则继续。
+  3. 失败 → `forceRelogin` 并写入 **returnUrl**(完整 path + query);登录成功后 `reLaunch`/`redirectTo` 回该页。
+- **不**在深链页单独实现选手机号 UI;复用现有「微信授权登录」(微信系统面板可选「上次提供」等多个号)。
+
+### 6.4 与现有登录设计的关系
+
+- 冷启动全局策略仍可保持「有本地 JWT 则用」;**仅深链入口**增加静默恢复,避免点通知必进登录页。
+- 一微信多手机号:仅未绑定/需重新授权时出现;截图所示为微信原生能力,本需求不新增选号交互。
+
+---
+
+## 7. 错误处理与体验
+
+| 情况 | 行为 |
+|------|------|
+| 未关注 / 取消关注 | 发送失败或未绑定;OA 正常;引导重新关注并绑定 |
+| 未绑定 mp_openid | 不发送;个人中心可提示 |
+| 模板字段不符 | 发送失败日志;需运营修正模板 |
+| 小程序未发布 / 未关联 | 点击无法跳小程序;发布前勿对生产用户推送 |
+| 任务已办结仍点开 | 详情页只读或提示已处理(沿用现有详情权限逻辑) |
+| 登录选错手机号 | 现有登录错误提示;换号或账号密码 |
+
+---
+
+## 8. 测试要点
+
+1. 审批待办产生 → 审批人收到审批模板;点击进详情且可审批。  
+2. 抄送产生 → 抄送人收到抄送模板;文案与审批模板可区分;点击为查看态。  
+3. 未绑定用户:OA 成功,无推送或日志为 skip。  
+4. 已绑定 + 清本地 token:点通知可静默进详情。  
+5. 未绑定小程序微信:点通知 → 登录页选号 → 回跳详情。  
+6. 登录页「关注服务号」:同主体下可打开公众号主页;失败出现二维码。  
+7. 并签多人:每人一条通知。  
+
+---
+
+## 9. 实现分期建议
+
+| 阶段 | 内容 |
+|------|------|
+| P0 | 字段与配置、服务号发送服务、OA 钩子、两套模板联调 |
+| P1 | 绑定 API + 个人中心绑定、登录页关注入口 |
+| P2 | 深链静默登录 + returnUrl、发送日志(可选表) |
+
+P0 在绑定未完成前可用测试号手工写入 `wx_mp_openid` 验证推送与跳转。
+
+---
+
+## 10. 实现约定(原开放项已拍板)
+
+| 项 | 约定 |
+|----|------|
+| 绑定方式 | **服务号网页授权 `snsapi_base`**:登录后经小程序 web-view 中间页拿 code → `POST /mini/auth/mp-bind` 换票写入。不做带参关注二维码主路径。 |
+| 前端配置 | `mobile-h5` 配置:`MP_GH_USERNAME`(原始 ID)、`MP_QRCODE_URL`(兜底二维码图)。密钥仅后端 `.env`。 |
+| 模板字段 | 微信后台申请通过后,将字段名写入后端映射配置(如 env 或常量);联调前用占位字段名,上线前替换为正式值。 |
+| 二维码资源 | 由运营提供服务号关注二维码图片,放入小程序 `static/` 或 CDN,并配置 `MP_QRCODE_URL`。 |
+
+运营依赖(非代码阻塞):服务号模板审核通过、小程序与服务号关联并已发布、开放平台同主体/主体关联。
+
+---
+
+## 11. 实现完成与运维检查表
+
+### 代码(本变更已落地)
+- [x] sys_user 服务号字段 + ORM
+- [x] 服务号模板发送 + OA 任务钩子
+- [x] 静默登录 / mp-bind API
+- [x] mobile-oa-h5 绑定页 + mobile-h5 关注/绑定/深链回跳
+
+### 运维待填(上线前)
+- [ ] backend `.env`: `WX_MP_APPID` / `WX_MP_SECRET` / `WX_MP_TEMPLATE_APPROVAL` / `WX_MP_TEMPLATE_CC` / `WX_MP_TPL_FIELD_*`
+- [ ] `mobile-oa-h5` `VITE_WX_MP_APPID`
+- [ ] `mobile-h5/utils/config.js` `MP_GH_USERNAME` + 真实 `static/mp-qrcode.png`
+- [ ] 微信:服务号关联小程序、开放平台、网页授权回调域名、小程序业务域名
+- [ ] 生产库执行 `docs/sql/alter-sys-user-wx-mp-openid.sql`(若未执行)
+
+### 真机验收(对照 §8)
+- [ ] 审批推送可批
+- [ ] 抄送推送可区分 + 查看态
+- [ ] 未绑定 skip
+- [ ] 静默登录
+- [ ] 选手机号登录回跳
+- [ ] 关注入口
+- [ ] 并签多人各一条

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio