fix: 调整证件提醒标题并取消微信通知的 Web 跳转,以保留纯提醒和小程序入口。

This commit is contained in:
wuzilong committed 2026-10-30 12:19:01 +08:00
1 parent cd99b040aa
commit 1ae8603045
9 files changed
+40 -52

No files matched your search

@@ -172,27 +172,6 @@ def _valid_now(db, notice):
return any(t.task_key == notice.task_key and t.user_id == notice.recipient_id for t in snapshot.pending)
def _fallback_url(notice):
base = settings.WECHAT_OA_PUBLIC_BASE_URL.rstrip("/")
if not base:
return ""
if notice.kind == "cert_daily":
return base + "/#/pl/document-reminder-module/document-reminder"
if notice.business_type == "pl_freight":
route = "/pl/freight-fee/freight-approval" if notice.kind == "pending" else "/pl/freight-fee/freight"
return base + "/#" + route
if notice.kind == "pending":
route = {
"bx_advance": "/bx/advance/approve",
"bx_reimburse": "/bx/reimburse/approve",
"pl_recharge": "/vehicle-mgmt/etc-recharge/vehicle-recharge-approval",
}.get(notice.business_type)
if route:
return base + "/#" + route
# 原生页无 Web 对等详情时回门户;关联小程序配置后优先用 page。
return base + "/#/"
def deliver_pending(db, *, client=None, limit=20):
if not settings.WECHAT_OA_ENABLED:
return 0
@@ -230,7 +209,7 @@ def deliver_pending(db, *, client=None, limit=20):
row.message_id = client.send(
openid=binding.openid, template_id=row.template_id,
data=json.loads(row.payload), event_key=row.event_key,
page=row.page, url=_fallback_url(row),
page=row.page,
)
row.status, row.sent_at, row.last_error = "sent", datetime.now(), None
count += 1
+3 -3
View File
@@ -95,10 +95,10 @@ def _active_cert_counts(db: Session, user: SysUser, today: date) -> tuple[int, i
def _payload(now: datetime, upcoming: int, expired: int) -> dict:
parts = []
if upcoming:
parts.append(f"{upcoming}项即将到期")
parts.append(f"到期{upcoming}项")
if expired:
parts.append(f"{expired}项已到期")
title = ",".join(parts)
parts.append(f"已到期{expired}项")
title = "证件" + ",".join(parts)
if len(title) > 20:
title = f"{upcoming + expired}项证件到期提醒"
return {
@@ -56,7 +56,7 @@ def get_snapshot(
if current and (not recharge or action == "approve"):
if current.approver_id:
snapshot.pending = [PendingApprover(current.id, current.approver_id)]
# 现有小程序仅详情/申请表单,无审批按钮;待办走Web审批入口。
# 现有小程序仅详情/申请表单,无审批按钮;待办仅提醒,不提供跳转。
snapshot.page = ""
return snapshot
+1 -3
View File
@@ -81,12 +81,10 @@ class WechatOAClient:
raise WechatAPIError(-1)
return data["openid"]
def send(self, *, openid, template_id, data, event_key, page="", url=""):
def send(self, *, openid, template_id, data, event_key, page=""):
payload = {"touser": openid, "template_id": template_id, "data": data, "client_msg_id": event_key}
if page and settings.WECHAT_OA_MINI_APPID:
payload["miniprogram"] = {"appid": settings.WECHAT_OA_MINI_APPID, "pagepath": page}
if url:
payload["url"] = url
result = self.authenticated("POST", "cgi-bin/message/template/send", sending=True, json=payload)
if not result.get("msgid"):
raise WechatDeliveryUnknown("微信未返回消息编号,请核对发送记录")
+15 -8
View File
@@ -157,16 +157,23 @@ def test_explicit_transient_failure_is_bounded_retry(notices):
assert db.query(Notice).one().status == 'failed'
@pytest.mark.parametrize('biz_type,path', [
('bx_advance', '/bx/advance/approve'),
('bx_reimburse', '/bx/reimburse/approve'),
('pl_recharge', '/vehicle-mgmt/etc-recharge/vehicle-recharge-approval'),
@pytest.mark.parametrize('biz_type,page', [
('bx_advance', ''), ('bx_reimburse', ''), ('pl_recharge', ''),
('pl_freight', ''),
('crm_quote', 'pages/common/mp-webview?path=quote-detail'),
('crm_contract', 'pages/common/mp-webview?path=contract-detail'),
])
def test_pending_links_to_real_approval_screen(monkeypatch, biz_type, path):
from types import SimpleNamespace
from app.services import wechat_approval_notices as svc
def test_worker_preserves_mini_page_without_web_fallback(notices, monkeypatch, biz_type, page):
svc, db, snapshot, Notice, Binding = notices
monkeypatch.setattr(svc.settings, 'WECHAT_OA_PUBLIC_BASE_URL', 'https://portal.example.test')
assert svc._fallback_url(SimpleNamespace(business_type=biz_type, kind='pending')) == 'https://portal.example.test/#' + path
snapshot.page = page
bind(db, Binding)
svc.sync_approval_notifications(db, biz_type, 'x', 't1', new_cycle=True)
db.commit()
client = FakeClient()
assert svc.deliver_pending(db, client=client) == 1
assert client.calls[0]['page'] == page
assert not client.calls[0].get('url')
def test_claimed_assignment_invalid_after_transfer_back(notices):
+4 -4
View File
@@ -89,7 +89,7 @@ def test_daily_dedup_template_and_tenant_scope(cert_notices):
assert notice.cycle_id == "2026-10-26"
assert notice.template_id == svc.settings.WECHAT_OA_TEMPLATE_CERT_REMINDER
assert json.loads(notice.payload) == {
"thing5": {"value": "1项即将到期"},
"thing5": {"value": "证件到期1项"},
"time6": {"value": "2026-10-26 10:00:00"},
"thing3": {"value": "系统默认发出"},
}
@@ -198,7 +198,7 @@ def test_duplicate_reminders_and_stale_remaining_days_do_not_change_daily_gate(c
days_remaining=900, create_by="t1-a"))
assert svc.enqueue_daily_cert_notices(db, now=now) == 1
assert db.query(Notice).count() == 1
assert json.loads(db.query(Notice).one().payload)["thing5"]["value"] == "1项即将到期"
assert json.loads(db.query(Notice).one().payload)["thing5"]["value"] == "证件到期1项"
cert.expire_date = now.date() + timedelta(days=31)
assert svc.refresh_cert_notice(db, db.query(Notice).one(), now=now) is False
@@ -211,10 +211,10 @@ def test_summary_counts_expired_and_upcoming_and_refreshes_after_renewal(cert_no
add_cert("hidden", creator="other", days=-1)
svc.enqueue_daily_cert_notices(db, now=now)
notice = db.query(Notice).one()
assert json.loads(notice.payload)["thing5"]["value"] == "1项即将到期,2项已到期"
assert json.loads(notice.payload)["thing5"]["value"] == "证件到期1项,已到期2项"
expired.expire_date = now.date() + timedelta(days=60)
assert svc.refresh_cert_notice(db, notice, now=now) is True
assert json.loads(notice.payload)["thing5"]["value"] == "1项即将到期,1项已到期"
assert json.loads(notice.payload)["thing5"]["value"] == "证件到期1项,已到期1项"
def test_long_summary_uses_short_total_title(cert_notices):
+5 -1
View File
@@ -40,11 +40,12 @@ def test_daily_worker_refreshes_real_counts_and_send_time(cert_notices, monkeypa
client = Client()
assert deliver_pending(db, client=client) == 1
assert client.calls[0]['data'] == {
'thing5': {'value': '1项即将到期,1项已到期'},
'thing5': {'value': '证件到期1项,已到期1项'},
'thing3': {'value': '系统默认发出'},
'time6': {'value': '2026-10-26 10:05:00'},
}
assert db.query(Notice).one().status == 'sent'
assert not client.calls[0].get('url')
assert deliver_pending(db, client=client) == 0
@@ -59,10 +60,13 @@ def test_completion_delivered_once_without_approval_cycle(completion_db, kind):
assert row is not None
recipient = row.recipient_id
expected = json.loads(row.payload)
expected_page = row.page
binding(db, recipient)
client = Client()
assert deliver_pending(db, client=client) == 1
assert client.calls[0]['data'] == expected
assert client.calls[0]['page'] == expected_page
assert bool(expected_page) == (kind != 'pl_freight')
assert 'amount' not in json.dumps(expected)
assert db.query(WechatNotice).one().status == 'sent'
assert deliver_pending(db, client=client) == 0
+3 -4
View File
@@ -162,11 +162,10 @@ def test_worker_dispatches_independent_events_without_approval_cycle(db, monkeyp
assert worker.deliver_pending(db, client=client) == 1
assert checked == [row.id]
assert sent[0]['openid'] == 'service-openid'
assert not sent[0].get('url')
assert worker.deliver_pending(db, client=client) == 0
def test_cert_daily_uses_existing_reminder_list(db, monkeypatch):
from app.services.wechat_approval_notices import _fallback_url
monkeypatch.setattr(settings, 'WECHAT_OA_PUBLIC_BASE_URL', 'https://portal.example')
def test_cert_daily_has_no_mini_page(db):
row = enqueue(db, kind='cert_daily', business_type='pl_cert_expiry')
assert _fallback_url(row) == 'https://portal.example/#/pl/document-reminder-module/document-reminder'
assert not row.page
+7 -6
View File
@@ -12,7 +12,7 @@
必选3模板 `rnPOIVknPqfgwVzKbchj6pCMCWpoDe3h3dkrFbUEsD4`:thing3 为“备用金发放/报销支付/运费发放/充值发放”,thing4 为完整业务单号,thing5 为“发放完成/支付完成”,time12 为真实处理时间,无金额字段。单号为空或超过20字符记为 failed,不截断发送。BX重复完成操作保留首次处理时间;撤销充值发放或更换关联申请时取消尚未发出的旧通知。
必选6模板 `aYzLZ_EqI8hq7RXrlf73BTAfNvzgJHeg_xWKar01Md0`:thing5 示例“3项即将到期,2项已到期”,time6 为实际发送的北京时间,thing3 **固定为“系统默认发出”**。当天到期计入“已到期”,沿用提醒列表统计口径。
必选6模板 `aYzLZ_EqI8hq7RXrlf73BTAfNvzgJHeg_xWKar01Md0`:thing5 示例“证件到期3项,已到期2项”(前项统计即将到期证件),仅即将到期时为“证件到期3项”,仅已到期时为“证件已到期2项”;time6 为实际发送的北京时间,thing3 **固定为“系统默认发出”**。当天到期计入“已到期”,沿用提醒列表统计口径。
1. 执行 `docs/sql/20261027_wechat_cert_notice_role.sql`。脚本兼容 MySQL 5.7,使用临时存储过程,需要 CREATE ROUTINE 权限;为已有租户幂等创建“证件到期通知接收人”角色,并关联证件提醒 Web 菜单及祖先目录。
2. 在系统角色/用户管理中给指定人员分配该角色;脚本不自动分配人员。角色名称需要与 `WECHAT_OA_CERT_NOTICE_ROLE_NAME` 一致。
@@ -51,7 +51,7 @@ WECHAT_OA_CERT_REMINDER_HOUR=10
WECHAT_OA_CERT_REMINDER_MINUTE=0
```
`WECHAT_OA_MINI_APPID` 必须是已关联的门户小程序,不能填服务号AppID。未配置时消息使用门户Web回退入口。小程序与服务号OpenID分别保存,不能互换。
`WECHAT_OA_MINI_APPID` 必须是已关联的门户小程序,不能填服务号AppID。未配置时消息仅提醒,不提供跳转;不再回退到门户Web。小程序与服务号OpenID分别保存,不能互换。
证件提醒开关同时受 `WECHAT_OA_ENABLED` 总开关控制;修改小时/分钟需要重启调度器。证件扫描与微信摘要使用同一调度任务,扫描仍每日执行,微信入队和发送复核均限制在周一、周三、周五。部署时如私有环境仍显式配置旧值8,须改为10并重启后端;本次未修改私有环境。通知关闭时仍执行原证件扫描;已安装队列表时,充值撤销操作仍会取消旧完成通知,避免重新启用后复活。
@@ -80,10 +80,11 @@ WECHAT_OA_CERT_REMINDER_MINUTE=0
### 消息跳转
- 报价、合同:统一小程序 WebView 详情。
- 备用金、报销:待办跳现有Web审批列表,结果跳小程序详情。当前小程序详情没有审批按钮。
- 充值:待办跳Web充值审批列表,结果跳现有小程序申请详情。当前小程序表单没有审批按钮。
- 运费:现有仓库没有移动运费审批页,消息使用Web运费审批/结算入口;需登录门户处理。本次不扩建运费移动功能。
- 发放完成:备用金、报销、充值复用已有小程序详情,运费进入Web结算入口。证件汇总进入 `/#/pl/document-reminder-module/document-reminder`;按现有登录和数据权限查看。
- 备用金、报销:待办仅提醒,不提供跳转;结果跳小程序详情。当前小程序详情没有审批按钮。
- 充值:待办仅提醒,不提供跳转;结果跳现有小程序申请详情。当前小程序表单没有审批按钮。
- 运费:审批待办、审批结果、发放完成均仅提醒,不提供跳转。
- 发放完成:备用金、报销、充值复用已有小程序详情。证件汇总仅提醒,不提供跳转。
- 所有通知停止附带 Web URL,包括小程序无法打开时的门户回退;待发送队列也按新规则发送。公网基址仍用于服务号绑定授权,不应删除。已送达的历史消息链接不受此次代码变更影响。
- 点击消息不授予额外业务权限,依赖现有登录、菜单和单据权限。若未登录小程序,现有登录流程可能回到工作台,需要重新点击原消息。
## 发送记录与失败处理