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
| 路径 | 职责 |
|---|---|
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 业务逻辑、企微、小程序订阅消息主通道、既有支付逻辑。
Files:
docs/sql/alter-sys-user-wx-mp-openid.sqlModify: 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`);
在 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="服务号绑定时间")
Expected: 三列存在;多行 wx_mp_openid 为 NULL 可共存。
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
)"
Files:
backend/app/core/config.pybackend/.env.examplebackend/app/services/wechat_mp_auth.pybackend/app/services/wechat_mp_message.pyCreate: 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
cd backend && source venv/bin/activate && pytest tests/test_wechat_mp_message.py::test_send_approval_requires_mp_openid -v
Expected: FAIL(模块不存在)或 assertion 相关。
在 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 同步注释说明。
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")}
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:
...
cd backend && source venv/bin/activate && pytest tests/test_wechat_mp_message.py -v
Expected: PASS
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
)"
Files:
backend/app/services/oa_wechat_notify.pybackend/app/services/oa_engine.pybackend/tests/test_oa_wechat_notify_hook.pyModify: 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()
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])。
同步改测试中忽略返回值的调用(仍可丢弃返回值)。
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。
# 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
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
)"
Files:
backend/app/services/wechat_login_service.pybackend/app/api/mini_auth.pyCreate: 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
wechat_login 路由逻辑:
code2session(login_code)phoneCode:走 resolve_wechat_login_by_openid_only;失败则返回现有文案「请授权手机号…」phoneCode:保持现有 resolve_wechat_login_user;成功时可把 sess.get("unionid") 写入 user.wx_unionid(若空则填)issue_tokens_for_user用 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
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
)"
Files:
backend/app/crud/user.pybackend/app/api/mini_auth.pyCreate: 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()
)
@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})
cd backend && source venv/bin/activate && pytest tests/test_mp_bind.py -v
Expected: PASS
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
)"
Files:
mobile-oa-h5/src/oa/mp-bind/(路由页,如 MpBindPage.vue + router)确保该路径在服务号「网页授权回调域名」与小程序业务域名白名单内(运维配置,计划中写明检查项)
[ ] Step 1: 页面行为
token(小程序传入)、可选 redirect。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_redirectappid 由构建时 env VITE_WX_MP_APPID 注入,勿把 secret 放前端。)code:POST /api/mini/auth/mp-bind,Header Authorization: Bearer {token},body { code }。wx.miniProgram.navigateBack。[ ] 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
)"
Files:
mobile-h5/utils/config.jsmobile-h5/pages/login/index.vuemobile-h5/styles/components/login.scssCreate: 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/'
文案:关注服务号,接收审批通知
点击 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
)"
Files:
mobile-h5/pages/oa/mp-bind/index.vuemobile-h5/pages.jsonmobile-h5/pages/index/index.vueModify: 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": "微信审批通知" }
}
文案:开启微信审批通知(已绑定可显示「已开启」灰色不可点或点击提示已绑定)。
onShow 调 GET /mini/auth/mp-bind-status(需登录)。
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
)"
Files:
mobile-h5/utils/auth-session.jsmobile-h5/utils/auth.jsmobile-h5/pages/oa/webview/index.vueModify: 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
...
}
silentWechatLogin in auth.jsexport 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
}
onLoad伪代码顺序:
getToken() 有值 → 现有逻辑拼 H5。try { await silentWechatLogin() } 成功则拼 H5。forceRelogin('请先登录', '/pages/oa/webview/index?' + queryString)。密码登录 / 微信登录成功分支末尾:
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
)"
Files:
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 |
| 网页授权回调域名、小程序关联服务号、业务域名 | 微信后台 |
./scripts/update-graph.sh backend
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 要求 | 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 完成后即可 |
无 TBD 实现步骤;模板字段名用可配置 env,联调时填正式值。