2026-08-13-oa-wechat-service-notify.md 27 KB

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

-- 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 后增加:

    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
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 未配置)

# 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 失败
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 中(微信模板消息配置附近)增加:

    # 微信服务号(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
"""微信服务号: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 汉字内,按微信规则保守截断)。

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)并跑通
cd backend && source venv/bin/activate && pytest tests/test_wechat_mp_message.py -v

Expected: PASS

  • Step 7: Commit
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

"""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() 之后调度
    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)
# 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
cd backend && source venv/bin/activate && pytest tests/test_oa_wechat_notify_hook.py -v

Expected: PASS

  • Step 5: Commit
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

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 文案。

cd backend && source venv/bin/activate && pytest tests/test_wechat_login_silent.py -v

Expected: PASS

  • Step 4: Commit
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

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: 路由
@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)
cd backend && source venv/bin/activate && pytest tests/test_mp_bind.py -v

Expected: PASS

  • Step 4: Commit
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

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

/** 服务号原始 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:

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

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 注册

{
  "path": "pages/oa/mp-bind/index",
  "style": { "navigationBarTitleText": "微信审批通知" }
}
  • Step 3: 首页 meta 区加次要入口

文案:开启微信审批通知(已绑定可显示「已开启」灰色不可点或点击提示已绑定)。
onShow 调 GET /mini/auth/mp-bind-status(需登录)。

  • Step 4: Commit
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 工具

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
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: 登录成功后

密码登录 / 微信登录成功分支末尾:

const back = takeReturnUrl()
if (back) {
  uni.reLaunch({ url: back.startsWith('/') ? back : '/' + back })
  return
}
// 原跳转首页
  • [ ] Step 5: 手测

  • 已登录点模板(开发者工具编译模式可模拟 path)进详情。

  • 清 storage 后,已绑 openid 账号应静默进。

  • 未绑应回登录,登录后回详情。

  • [ ] Step 6: Commit

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: 更新图谱
./scripts/update-graph.sh backend
  • Step 4: Commit(若有 graph / 文档变更)
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,联调时填正式值。