refactor: 钉钉审批独立为 dingdingflow 模块,cms 侧剔除

- 删除 cms/dingtalk_client.py、dd_approvals*/dd_approval_configs* 的 models/json/dspy(共14文件)
- cms/init.py 删钉钉段 355 行(792->450)
- submit_content_for_approval 改走 ServerEnv 软依赖调 submit_approval,
  未加载 dingdingflow 时只置 pending 不报错
- 新增 on_content_approval_done 回调钩子:原先硬编码在钉钉回调里的
  cms_content 回写逻辑归位到 cms 自己,由 load_cms() 注册到 content_publish
- load_path 移除 11 条钉钉路径;i18n 移除钉钉专属文案;README 更新依赖说明
This commit is contained in:
yumoqing 2026-08-25 18:17:04 +08:00
parent ea92c8f142
commit 20602fadfb
19 changed files with 54 additions and 1256 deletions

View File

@ -1,6 +1,6 @@
# CMS 内容管理模块
企业官网内容管理与钉钉审批工作流模块基于Sage/bricks-framework开发。
企业官网内容管理模块基于Sage/bricks-framework开发。
## 功能
@ -9,7 +9,7 @@
- **栏目管理**: 官网页面栏目配置
- **商机线索**: 网站访客提交 + AI抽取
- **站点配置**: Hero标语、页脚等KV配置
- **钉钉审批**: 内容发布审批工作流
- **内容审批**: 发起发布审批(审批能力由 dingdingflow 独立模块提供)
## 数据库表
@ -20,8 +20,6 @@
| cms_sections | 栏目管理 |
| cms_leads | 商机线索 |
| cms_site_config | 站点配置 |
| dd_approvals | 审批记录 |
| dd_approval_configs | 审批流程配置 |
## 安装
@ -49,12 +47,17 @@ def init():
- cms_sections: 默认栏目配置
- dd_approval_configs: 默认审批配置
## 环境变量 (钉钉审批)
## 审批依赖
```
DINGTALK_APP_KEY=xxx
DINGTALK_APP_SECRET=xxx
DINGTALK_AGENT_ID=xxx
内容审批走 **dingdingflow** 独立模块(软依赖):
```python
from cms.init import load_cms
from dingdingflow.init import load_dingdingflow
load_dingdingflow() # 必须先加载cms 才能注册审批回调钩子
load_cms()
```
缺少环境变量时自动使用mock响应。
未加载 dingdingflow 时,`submit_content_for_approval` 只把内容置为 pending
并返回提示,不报错。钉钉凭据/审批模板配置见 dingdingflow 的 README。

View File

@ -1,6 +1,7 @@
"""
CMS - 企业官网内容管理与审批工作流模块
合并原 entcms + dingdingflow 两个子模块
"""CMS - 企业官网内容管理模块
钉钉审批已独立为 dingdingflow 模块本模块通过 ServerEnv 软依赖调用其
submit_approval并注册 content_publish 的审批结果回调钩子
"""
from .init import load_cms

View File

@ -1,240 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DingTalk API Client for approval workflow integration.
Reads credentials from environment variables (DINGTALK_APP_KEY, DINGTALK_APP_SECRET, DINGTALK_AGENT_ID).
Gracefully handles missing credentials by returning mock responses in dev mode.
"""
import os
import json
import logging
import time
import urllib.request
import urllib.error
logger = logging.getLogger(__name__)
class DingTalkClient:
"""Client for DingTalk Open API - approval workflow operations."""
BASE_URL = "https://oapi.dingtalk.com"
def __init__(self):
self.app_key = os.environ.get("DINGTALK_APP_KEY", "")
self.app_secret = os.environ.get("DINGTALK_APP_SECRET", "")
self.agent_id = os.environ.get("DINGTALK_AGENT_ID", "")
self.callback_token = os.environ.get("DINGTALK_CALLBACK_TOKEN", "")
self._access_token = None
self._token_expires_at = 0
if not self.app_key or not self.app_secret:
logger.warning(
"DingTalk credentials not configured (DINGTALK_APP_KEY / DINGTALK_APP_SECRET). "
"Running in dev/mock mode - API calls will return mock responses."
)
@property
def is_dev_mode(self):
"""Return True if credentials are missing (dev/mock mode)."""
return not self.app_key or not self.app_secret
def _http_post(self, url, data=None, params=None):
"""Make HTTP POST request and return parsed JSON response."""
if params:
query = "&".join(f"{k}={v}" for k, v in params.items())
url = f"{url}?{query}"
body = json.dumps(data).encode("utf-8") if data else b""
req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_data = resp.read().decode("utf-8")
return json.loads(resp_data)
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8", errors="replace")
logger.error("DingTalk API HTTP error %s: %s", e.code, error_body)
return {"errcode": e.code, "errmsg": f"HTTP {e.code}: {error_body}"}
except urllib.error.URLError as e:
logger.error("DingTalk API connection error: %s", str(e))
return {"errcode": -1, "errmsg": f"Connection error: {str(e)}"}
except Exception as e:
logger.error("DingTalk API unexpected error: %s", str(e))
return {"errcode": -1, "errmsg": str(e)}
def _http_get(self, url, params=None):
"""Make HTTP GET request and return parsed JSON response."""
if params:
query = "&".join(f"{k}={v}" for k, v in params.items())
url = f"{url}?{query}"
req = urllib.request.Request(url, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
resp_data = resp.read().decode("utf-8")
return json.loads(resp_data)
except urllib.error.HTTPError as e:
error_body = e.read().decode("utf-8", errors="replace")
logger.error("DingTalk API HTTP error %s: %s", e.code, error_body)
return {"errcode": e.code, "errmsg": f"HTTP {e.code}: {error_body}"}
except Exception as e:
logger.error("DingTalk API unexpected error: %s", str(e))
return {"errcode": -1, "errmsg": str(e)}
def get_access_token(self):
"""
Get DingTalk access token. Caches token until expiry.
Returns token string or empty string on failure.
"""
if self.is_dev_mode:
logger.info("Dev mode: returning mock access token")
return "mock_access_token_dev"
# Return cached token if still valid (with 5-min buffer)
now = time.time()
if self._access_token and now < self._token_expires_at - 300:
return self._access_token
url = f"{self.BASE_URL}/gettoken"
params = {"appkey": self.app_key, "appsecret": self.app_secret}
result = self._http_get(url, params=params)
if result.get("errcode") == 0:
self._access_token = result.get("access_token", "")
expires_in = result.get("expires_in", 7200)
self._token_expires_at = now + expires_in
logger.info("DingTalk access token obtained, expires in %ss", expires_in)
return self._access_token
else:
logger.error(
"Failed to get DingTalk access token: %s",
result.get("errmsg", "unknown error"),
)
return ""
def create_approval_instance(self, process_code, form_data, originator_user_id):
"""
Create a DingTalk approval instance.
Args:
process_code: DingTalk approval template code (from dd_approval_configs)
form_data: List of form component values, e.g.:
[{"name": "审批类型", "value": "内容发布"}, ...]
originator_user_id: DingTalk user ID of the applicant
Returns:
dict with keys:
- success (bool)
- instance_id (str) - DingTalk process instance ID
- errmsg (str) - error message if failed
"""
if self.is_dev_mode:
mock_instance_id = f"mock_instance_{int(time.time())}"
logger.info(
"Dev mode: mock approval instance created: %s (process_code=%s)",
mock_instance_id,
process_code,
)
return {
"success": True,
"instance_id": mock_instance_id,
"errmsg": "",
}
token = self.get_access_token()
if not token:
return {"success": False, "instance_id": "", "errmsg": "Failed to get access token"}
url = f"{self.BASE_URL}/topapi/processinstance/create"
payload = {
"agent_id": int(self.agent_id) if self.agent_id else 0,
"process_code": process_code,
"originator_user_id": originator_user_id,
"dept_id": -1,
"form_component_values": form_data,
}
result = self._http_post(url, data=payload, params={"access_token": token})
if result.get("errcode") == 0:
instance_id = result.get("process_instance_id", "")
logger.info("DingTalk approval instance created: %s", instance_id)
return {"success": True, "instance_id": instance_id, "errmsg": ""}
else:
errmsg = result.get("errmsg", "unknown error")
logger.error("Failed to create DingTalk approval: %s", errmsg)
return {"success": False, "instance_id": "", "errmsg": errmsg}
def get_approval_instance(self, instance_id):
"""
Get DingTalk approval instance details.
Args:
instance_id: DingTalk process instance ID
Returns:
dict with keys:
- success (bool)
- status (str) - NEW/RUNNING/COMPLETED/TERMINATED
- result (str) - agree/refuse (only when status=COMPLETED)
- data (dict) - full instance data
- errmsg (str) - error message if failed
"""
if self.is_dev_mode:
logger.info("Dev mode: mock get approval instance: %s", instance_id)
return {
"success": True,
"status": "COMPLETED",
"result": "agree",
"data": {
"process_instance_id": instance_id,
"status": "COMPLETED",
"result": "agree",
},
"errmsg": "",
}
token = self.get_access_token()
if not token:
return {"success": False, "status": "", "result": "", "data": {}, "errmsg": "Failed to get access token"}
url = f"{self.BASE_URL}/topapi/processinstance/get"
payload = {"process_instance_id": instance_id}
result = self._http_post(url, data=payload, params={"access_token": token})
if result.get("errcode") == 0:
pi = result.get("process_instance", {})
status = pi.get("status", "")
pi_result = pi.get("result", "")
return {
"success": True,
"status": status,
"result": pi_result,
"data": pi,
"errmsg": "",
}
else:
errmsg = result.get("errmsg", "unknown error")
logger.error("Failed to get DingTalk approval instance: %s", errmsg)
return {"success": False, "status": "", "result": "", "data": {}, "errmsg": errmsg}
# Module-level singleton
_client_instance = None
def get_dingtalk_client():
"""Get or create the DingTalkClient singleton."""
global _client_instance
if _client_instance is None:
_client_instance = DingTalkClient()
return _client_instance

View File

@ -9,8 +9,7 @@ cms - 企业CMS内容管理与钉钉审批工作流模块
- CMS Leads CRUD (cms_leads_*)
- CMS Site Config CRUD (cms_site_config_*)
- 公开API (get_published_content, get_latest_news, get_content_detail, submit_lead, get_visible_sections, get_site_config, get_category_options)
- 钉钉审批 CRUD (dd_approvals_*, dd_approval_configs_*)
- 审批业务逻辑 (submit_approval, get_approval_status, handle_dingtalk_callback)
- 内容审批发起审批能力由 dingdingflow 独立模块提供本模块只注册结果回调钩子
"""
import json
import logging
@ -19,7 +18,6 @@ import datetime
from ahserver.serverenv import ServerEnv
from appPublic.uniqueID import getID
from sqlor.dbpools import DBPools
from .dingtalk_client import get_dingtalk_client
logger = logging.getLogger(__name__)
@ -353,374 +351,47 @@ async def get_content_detail(content_id):
# ═══════════════════════════════════════════════════════════════════════════════
async def submit_content_for_approval(content_id, title, applicant_id):
"""提交内容审批(调用钉钉审批流程)"""
"""提交内容审批。
审批能力由 dingdingflow 独立模块提供通过 ServerEnv 调用软依赖
未安装/未加载该模块时只把内容置为 pending不报错
"""
dbname = _get_dbname()
db = DBPools()
async with db.sqlorContext(dbname) as sor:
# 更新内容状态为pending
await sor.U('cms_content', {'id': content_id, 'status': 'pending'})
# 调用审批流程
result = await submit_approval('content_publish', content_id, title, applicant_id)
# 保存审批ID
if result and result.get('approval_id'):
env = ServerEnv()
submit_approval = getattr(env, 'submit_approval', None)
if submit_approval is None:
logger.warning('submit_content_for_approval: dingdingflow 未加载,'
'内容已置 pending 但未发起审批')
return {'success': False, 'message': '审批模块(dingdingflow)未加载'}
result = await submit_approval('content_publish', content_id, title, applicant_id)
if result and result.get('approval_id'):
async with db.sqlorContext(dbname) as sor:
await sor.U('cms_content', {'id': content_id, 'approval_id': result['approval_id']})
return result
return result
# ═══════════════════════════════════════════════════════════════════════════════
# DD Approvals CRUD (原 dingdingflow)
# ═══════════════════════════════════════════════════════════════════════════════
async def on_content_approval_done(biz_id, status, approval_id, comment):
"""dingdingflow 审批结果回调钩子biz_type='content_publish')。
async def dd_approvals_create(data):
"""创建审批记录"""
dbname = _get_dbname()
db = DBPools()
data['id'] = getID()
if 'org_id' not in data:
data['org_id'] = '0'
if 'status' not in data:
data['status'] = 'pending'
if 'created_at' not in data:
data['created_at'] = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
async with db.sqlorContext(dbname) as sor:
await sor.C('dd_approvals', data)
return {'id': data['id']}
async def dd_approvals_update(data):
"""更新审批记录"""
dbname = _get_dbname()
db = DBPools()
record_id = data.get('id')
if not record_id:
raise ValueError('id is required for update')
async with db.sqlorContext(dbname) as sor:
await sor.U('dd_approvals', data)
return {'id': record_id}
async def dd_approvals_delete(data):
"""删除审批记录"""
dbname = _get_dbname()
db = DBPools()
async with db.sqlorContext(dbname) as sor:
await sor.D('dd_approvals', data)
return data
async def dd_approvals_list(ns=None):
"""查询审批记录列表"""
dbname = _get_dbname()
db = DBPools()
async with db.sqlorContext(dbname) as sor:
ns = ns or {}
ns.setdefault('sort', 'created_at desc')
rows = await sor.R('dd_approvals', ns)
return {'rows': rows, 'total': len(rows)}
# ═══════════════════════════════════════════════════════════════════════════════
# DD Approval Configs CRUD (原 dingdingflow)
# ═══════════════════════════════════════════════════════════════════════════════
async def dd_approval_configs_create(data):
"""创建审批配置"""
dbname = _get_dbname()
db = DBPools()
data['id'] = getID()
if 'org_id' not in data:
data['org_id'] = '0'
if 'is_active' not in data:
data['is_active'] = '1'
now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
if 'created_at' not in data:
data['created_at'] = now_str
data['updated_at'] = now_str
async with db.sqlorContext(dbname) as sor:
await sor.C('dd_approval_configs', data)
return {'id': data['id']}
async def dd_approval_configs_update(data):
"""更新审批配置"""
dbname = _get_dbname()
db = DBPools()
record_id = data.get('id')
if not record_id:
raise ValueError('id is required for update')
data['updated_at'] = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
async with db.sqlorContext(dbname) as sor:
await sor.U('dd_approval_configs', data)
return {'id': record_id}
async def dd_approval_configs_delete(data):
"""删除审批配置"""
dbname = _get_dbname()
db = DBPools()
async with db.sqlorContext(dbname) as sor:
await sor.D('dd_approval_configs', data)
return data
async def dd_approval_configs_list(ns=None):
"""查询审批配置列表"""
dbname = _get_dbname()
db = DBPools()
async with db.sqlorContext(dbname) as sor:
ns = ns or {}
ns.setdefault('sort', 'biz_type')
rows = await sor.R('dd_approval_configs', ns)
return {'rows': rows, 'total': len(rows)}
async def get_approval_config_by_type(org_id, biz_type):
"""根据org_id和biz_type获取审批配置"""
dbname = _get_dbname()
db = DBPools()
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('dd_approval_configs', {'org_id': org_id, 'biz_type': biz_type})
if rows:
return rows[0]
return None
# ═══════════════════════════════════════════════════════════════════════════════
# Approval Workflow Business Logic (原 dingdingflow)
# ═══════════════════════════════════════════════════════════════════════════════
async def submit_approval(biz_type, biz_id, title, applicant_id, org_id='0'):
load_cms() 注册审批通过published驳回draft
这是原先硬编码在钉钉回调里的 cms_content 回写逻辑现归位到 cms 自己
"""
提交审批请求:
1. 查找审批配置
2. 创建审批记录
3. 调用钉钉API创建审批实例
4. 保存钉钉实例ID
"""
client = get_dingtalk_client()
# 查找审批配置
config = await get_approval_config_by_type(org_id, biz_type)
if not config:
logger.error('No approval config found for org_id=%s, biz_type=%s', org_id, biz_type)
return {'success': False, 'message': f'No approval config found for biz_type={biz_type}'}
process_code = config.get('process_code', '') if isinstance(config, dict) else getattr(config, 'process_code', '') or ''
form_config_raw = config.get('form_config', '') if isinstance(config, dict) else getattr(config, 'form_config', '') or ''
# 从form_config构建表单数据
form_data = []
if form_config_raw:
try:
form_config = json.loads(form_config_raw) if isinstance(form_config_raw, str) else form_config_raw
if isinstance(form_config, list):
form_data = form_config
except (json.JSONDecodeError, TypeError) as e:
logger.warning('Failed to parse form_config: %s', str(e))
# 无表单数据时创建最小表单
if not form_data:
form_data = [
{'name': '审批标题', 'value': title},
{'name': '业务类型', 'value': biz_type},
]
# 调用钉钉API
result = client.create_approval_instance(process_code, form_data, applicant_id)
if not result['success']:
# API失败仍然创建记录
approval_data = {
'biz_type': biz_type,
'biz_id': biz_id,
'title': title,
'applicant_id': applicant_id,
'org_id': org_id,
'status': 'pending',
'dingtalk_instance_id': '',
'comment': f"DingTalk API error: {result.get('errmsg', '')}",
}
approval = await dd_approvals_create(approval_data)
return {
'success': False,
'message': f"DingTalk API failed: {result.get('errmsg', '')}",
'approval_id': approval['id'],
}
# 创建审批记录
approval_data = {
'biz_type': biz_type,
'biz_id': biz_id,
'title': title,
'applicant_id': applicant_id,
'org_id': org_id,
'status': 'pending',
'dingtalk_instance_id': result['instance_id'],
}
approval = await dd_approvals_create(approval_data)
logger.info(
'Approval submitted: id=%s, instance=%s, biz=%s/%s',
approval['id'], result['instance_id'], biz_type, biz_id,
)
return {
'success': True,
'message': 'Approval submitted successfully',
'approval_id': approval['id'],
'instance_id': result['instance_id'],
}
async def get_approval_status(approval_id):
"""查询钉钉审批最新状态并同步到本地"""
content_status = {'approved': 'published', 'rejected': 'draft'}.get(status, '')
if not content_status:
return
data = {'id': biz_id, 'status': content_status}
if content_status == 'published':
data['published_at'] = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
dbname = _get_dbname()
db = DBPools()
async with DBPools().sqlorContext(dbname) as sor:
await sor.U('cms_content', data)
logger.info('cms_content %s -> %s (approval %s)', biz_id, content_status, approval_id)
# 获取本地记录
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('dd_approvals', {'id': approval_id})
if not rows:
return {'success': False, 'message': 'Approval record not found'}
record = rows[0]
instance_id = record.get('dingtalk_instance_id', '') if isinstance(record, dict) else getattr(record, 'dingtalk_instance_id', '')
current_status = record.get('status', '') if isinstance(record, dict) else getattr(record, 'status', '')
# 已完成无需再查
if current_status in ('approved', 'rejected', 'cancelled'):
return {
'success': True,
'status': current_status,
'approval_id': approval_id,
'instance_id': instance_id,
}
if not instance_id:
return {
'success': True,
'status': current_status,
'approval_id': approval_id,
'instance_id': '',
'message': 'No DingTalk instance ID, cannot sync',
}
# 查询钉钉
client = get_dingtalk_client()
dt_result = client.get_approval_instance(instance_id)
if not dt_result['success']:
return {
'success': False,
'message': f"DingTalk query failed: {dt_result.get('errmsg', '')}",
'status': current_status,
}
# 映射钉钉状态
dt_status = dt_result.get('status', '')
dt_result_val = dt_result.get('result', '')
new_status = current_status
if dt_status == 'COMPLETED':
if dt_result_val == 'agree':
new_status = 'approved'
elif dt_result_val == 'refuse':
new_status = 'rejected'
elif dt_status == 'TERMINATED':
new_status = 'cancelled'
# 更新本地记录
if new_status != current_status:
update_data = {'id': approval_id, 'status': new_status}
if new_status in ('approved', 'rejected', 'cancelled'):
update_data['completed_at'] = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
await dd_approvals_update(update_data)
logger.info('Approval %s status synced: %s -> %s', approval_id, current_status, new_status)
return {
'success': True,
'status': new_status,
'approval_id': approval_id,
'instance_id': instance_id,
}
async def handle_dingtalk_callback(data):
"""
处理钉钉webhook回调
钉钉在审批状态变化时发送回调
"""
logger.info('DingTalk callback received: %s', json.dumps(data, ensure_ascii=False))
instance_id = data.get('processInstanceId', '')
if not instance_id:
return {'success': False, 'message': 'Missing processInstanceId'}
callback_type = data.get('type', '')
if callback_type != 'bpms_instance_change':
logger.info('Ignoring callback type: %s', callback_type)
return {'success': True, 'message': f'Ignored callback type: {callback_type}'}
# 查找本地审批记录
dbname = _get_dbname()
db = DBPools()
async with db.sqlorContext(dbname) as sor:
rows = await sor.R('dd_approvals', {'dingtalk_instance_id': instance_id})
if not rows:
logger.warning('No local approval found for instance_id=%s', instance_id)
return {'success': False, 'message': f'No approval found for instance {instance_id}'}
record = rows[0]
record_id = record.get('id', '') if isinstance(record, dict) else getattr(record, 'id', '')
current_status = record.get('status', '') if isinstance(record, dict) else getattr(record, 'status', '')
# 映射回调状态
dt_result = data.get('result', '')
new_status = current_status
if dt_result == 'agree':
new_status = 'approved'
elif dt_result == 'refuse':
new_status = 'rejected'
elif callback_type == 'terminate':
new_status = 'cancelled'
# 更新记录
if new_status != current_status:
update_data = {
'id': record_id,
'status': new_status,
'comment': data.get('remark', ''),
}
if new_status in ('approved', 'rejected', 'cancelled'):
update_data['completed_at'] = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
await dd_approvals_update(update_data)
logger.info('Callback: approval %s updated to %s', record_id, new_status)
# 通知CMS内容状态变更
biz_type = record.get('biz_type', '') if isinstance(record, dict) else getattr(record, 'biz_type', '')
biz_id = record.get('biz_id', '') if isinstance(record, dict) else getattr(record, 'biz_id', '')
if biz_type == 'content_publish' and biz_id:
content_status = 'published' if new_status == 'approved' else 'draft' if new_status == 'rejected' else ''
if content_status:
content_update = {'id': biz_id, 'status': content_status}
if content_status == 'published':
content_update['published_at'] = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
async with db.sqlorContext(dbname) as sor:
await sor.U('cms_content', content_update)
logger.info('Callback: cms_content %s updated to %s', biz_id, content_status)
return {
'success': True,
'message': f'Approval {record_id} updated to {new_status}',
'approval_id': record_id,
'status': new_status,
}
# ═══════════════════════════════════════════════════════════════════════════════
# Module Loader
# ═══════════════════════════════════════════════════════════════════════════════
def load_cms():
"""注册所有CMS模块函数到ServerEnv"""
@ -766,26 +437,12 @@ def load_cms():
env.get_content_detail = get_content_detail
env.submit_content_for_approval = submit_content_for_approval
# DD Approvals CRUD
env.dd_approvals_create = dd_approvals_create
env.dd_approvals_update = dd_approvals_update
env.dd_approvals_delete = dd_approvals_delete
env.dd_approvals_list = dd_approvals_list
# DD Approval Configs CRUD
env.dd_approval_configs_create = dd_approval_configs_create
env.dd_approval_configs_update = dd_approval_configs_update
env.dd_approval_configs_delete = dd_approval_configs_delete
env.dd_approval_configs_list = dd_approval_configs_list
env.get_approval_config_by_type = get_approval_config_by_type
# Approval Business Logic
env.submit_approval = submit_approval
env.get_approval_status = get_approval_status
env.handle_dingtalk_callback = handle_dingtalk_callback
# DingTalk Client
env.get_dingtalk_client = get_dingtalk_client
# 审批结果回调钩子(审批能力在 dingdingflow 独立模块)
register_biz_handler = getattr(env, 'register_biz_handler', None)
if register_biz_handler:
register_biz_handler('content_publish', on_content_approval_done)
else:
logger.info('dingdingflow 未加载,跳过 content_publish 审批钩子注册')
logger.info('cms module loaded (v%s)', MODULE_VERSION)
return True

View File

@ -96,7 +96,4 @@ title is required: title is required
配置值: 配置值
配置组(hero/footer/contact/seo): 配置组(hero/footer/contact/seo)
配置键: 配置键
钉钉审批实例ID: 钉钉审批实例ID
钉钉审批模板编码: 钉钉审批模板编码
钉钉应用AgentId: 钉钉应用AgentId
静态内容(用于hero/cta等固定内容栏目): 静态内容(用于hero/cta等固定内容栏目)

View File

@ -1,25 +0,0 @@
{
"tblname": "dd_approval_configs",
"alias": "dd_approval_configs",
"title": "审批流程配置",
"params": {
"sortby": ["biz_type"],
"browserfields": {
"exclouded": ["id", "form_config"],
"alters": {
"is_active": {
"uitype": "code",
"data": [
{"value": "1", "text": "启用"},
{"value": "0", "text": "停用"}
]
}
}
},
"editable": {
"new_data_url": "{{entire_url('../api/dingdingflow/dd_approval_configs_create.dspy')}}",
"update_data_url": "{{entire_url('../api/dingdingflow/dd_approval_configs_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/dingdingflow/dd_approval_configs_delete.dspy')}}"
}
}
}

View File

@ -1,42 +0,0 @@
{
"tblname": "dd_approvals",
"alias": "dd_approvals",
"title": "审批记录",
"params": {
"sortby": ["created_at desc"],
"data_filter": {
"AND": [
{"field": "status", "op": "=", "var": "status_filter"},
{"field": "biz_type", "op": "=", "var": "biz_type_filter"},
{"field": "title", "op": "LIKE", "var": "title_filter"}
]
},
"browserfields": {
"exclouded": ["id"],
"alters": {
"status": {
"uitype": "code",
"data": [
{"value": "pending", "text": "待审批"},
{"value": "approved", "text": "已通过"},
{"value": "rejected", "text": "已拒绝"},
{"value": "cancelled", "text": "已取消"}
]
},
"biz_type": {
"uitype": "code",
"data": [
{"value": "content_publish", "text": "内容发布"},
{"value": "content_update", "text": "内容修改"},
{"value": "content_delete", "text": "内容删除"}
]
}
}
},
"editable": {
"new_data_url": "{{entire_url('../api/dingdingflow/dd_approvals_create.dspy')}}",
"update_data_url": "{{entire_url('../api/dingdingflow/dd_approvals_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/dingdingflow/dd_approvals_delete.dspy')}}"
}
}
}

View File

@ -1,85 +0,0 @@
{
"summary": [
{
"name": "dd_approval_configs",
"title": "审批流程配置表",
"primary": [
"id"
]
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "组织ID",
"type": "str",
"length": 32,
"default": "0"
},
{
"name": "biz_type",
"title": "业务类型",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "biz_type_title",
"title": "业务类型名称",
"type": "str",
"length": 100
},
{
"name": "process_code",
"title": "钉钉审批模板编码",
"type": "str",
"length": 100
},
{
"name": "agent_id",
"title": "钉钉应用AgentId",
"type": "str",
"length": 100
},
{
"name": "form_config",
"title": "表单字段配置JSON",
"type": "text"
},
{
"name": "is_active",
"title": "是否启用(1/0)",
"type": "str",
"length": 1,
"default": "1"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp"
},
{
"name": "updated_at",
"title": "更新时间",
"type": "timestamp"
}
],
"indexes": [
{
"name": "idx_apvcfg_org_type",
"idxtype": "unique",
"idxfields": [
"org_id",
"biz_type"
]
}
],
"codes": []
}

View File

@ -1,114 +0,0 @@
{
"summary": [
{
"name": "dd_approvals",
"title": "审批记录表",
"primary": [
"id"
]
}
],
"fields": [
{
"name": "id",
"title": "主键ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "org_id",
"title": "组织ID",
"type": "str",
"length": 32,
"default": "0"
},
{
"name": "biz_type",
"title": "业务类型(content_publish等)",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "biz_id",
"title": "业务数据ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "title",
"title": "审批标题",
"type": "str",
"length": 255,
"nullable": "no"
},
{
"name": "applicant_id",
"title": "申请人ID",
"type": "str",
"length": 32,
"nullable": "no"
},
{
"name": "approver_id",
"title": "审批人ID",
"type": "str",
"length": 32
},
{
"name": "dingtalk_instance_id",
"title": "钉钉审批实例ID",
"type": "str",
"length": 100
},
{
"name": "status",
"title": "状态(pending/approved/rejected/cancelled)",
"type": "str",
"length": 32,
"default": "pending"
},
{
"name": "comment",
"title": "审批意见",
"type": "text"
},
{
"name": "created_at",
"title": "创建时间",
"type": "timestamp"
},
{
"name": "completed_at",
"title": "完成时间",
"type": "datetime"
}
],
"indexes": [
{
"name": "idx_approval_biz",
"idxtype": "index",
"idxfields": [
"biz_type",
"biz_id"
]
},
{
"name": "idx_approval_status",
"idxtype": "index",
"idxfields": [
"org_id",
"status"
]
},
{
"name": "idx_approval_applicant",
"idxtype": "index",
"idxfields": [
"applicant_id"
]
}
]
}

View File

@ -78,20 +78,9 @@ PATHS = [
f"/{MOD}/api/cms_site_config_delete.dspy",
f"/{MOD}/api/cms_site_config_list.dspy",
# DD Approvals
f"/{MOD}/dd_approvals", f"/{MOD}/dd_approvals/%",
f"/{MOD}/api/dd_approvals_create.dspy",
f"/{MOD}/api/dd_approvals_update.dspy",
f"/{MOD}/api/dd_approvals_delete.dspy",
f"/{MOD}/api/dd_approvals_list.dspy",
# DD Approval Configs
f"/{MOD}/dd_approval_configs", f"/{MOD}/dd_approval_configs/%",
f"/{MOD}/api/dd_approval_configs_create.dspy",
f"/{MOD}/api/dd_approval_configs_update.dspy",
f"/{MOD}/api/dd_approval_configs_delete.dspy",
f"/{MOD}/api/dd_approval_configs_list.dspy",
# DingTalk
f"/{MOD}/api/submit_approval.dspy",
f"/{MOD}/api/dingtalk_callback.dspy",
# Export (in portal wwwroot/api/, not /cms/ prefix)
f"/api/export_leads.dspy",
f"/api/export_content.dspy",

View File

@ -1,37 +0,0 @@
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
biz_type = params_kw.get('biz_type', '')
biz_type_title = params_kw.get('biz_type_title', '')
process_code = params_kw.get('process_code', '')
if not biz_type:
result['options'] = {'title': 'Error', 'message': 'biz_type is required', 'type': 'error'}
else:
new_id = getID()
org_id = (await get_userorgid()) or '0'
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe(
"INSERT INTO dd_approval_configs (id, org_id, biz_type, biz_type_title, process_code, agent_id, form_config, is_active, created_at, updated_at) VALUES (${id}$, ${org_id}$, ${biz_type}$, ${biz_type_title}$, ${process_code}$, ${agent_id}$, ${form_config}$, ${is_active}$, ${created_at}$, ${updated_at}$)",
{
'id': new_id,
'org_id': org_id,
'biz_type': biz_type,
'biz_type_title': biz_type_title,
'process_code': process_code,
'agent_id': params_kw.get('agent_id', ''),
'form_config': params_kw.get('form_config', ''),
'is_active': params_kw.get('is_active', '1'),
'created_at': now_str,
'updated_at': now_str
}
)
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '审批配置创建成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'创建失败: {str(e)}', 'type': 'error'}
return result

View File

@ -1,16 +0,0 @@
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
record_id = params_kw.get('id', '')
if not record_id:
result['options'] = {'title': 'Error', 'message': 'ID is required', 'type': 'error'}
else:
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe("DELETE FROM dd_approval_configs WHERE id=${id}$", {'id': record_id})
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '审批配置删除成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'删除失败: {str(e)}', 'type': 'error'}
return result

View File

@ -1,54 +0,0 @@
result = {'success': False, 'rows': [], 'total': 0}
try:
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
where_clauses = []
where_ns = {}
# Optional filtering
is_active = params_kw.get('is_active', '')
if is_active:
where_clauses.append("is_active=${is_active}$")
where_ns['is_active'] = is_active
biz_type = params_kw.get('biz_type', '')
if biz_type:
where_clauses.append("biz_type=${biz_type}$")
where_ns['biz_type'] = biz_type
where_sql = " AND ".join(where_clauses)
where_prefix = " WHERE " if where_clauses else ""
# Count query
count_sql = "SELECT count(*) rcnt FROM dd_approval_configs" + where_prefix + where_sql
count_rows = await sor.sqlExe(count_sql, where_ns)
total = 0
if count_rows and len(count_rows) > 0:
r = count_rows[0]
total = getattr(r, 'rcnt', 0)
if total > 0:
ns = {
'page': int(params_kw.get('page', 1)),
'rows': int(params_kw.get('rows', 20)),
'sort': params_kw.get('sort', 'biz_type')
}
sql = "SELECT id, org_id, biz_type, biz_type_title, process_code, agent_id, form_config, is_active, created_at, updated_at FROM dd_approval_configs" + where_prefix + where_sql
query_ns = dict(list(ns.items()) + list(where_ns.items()))
rows = await sor.sqlExe(sql, query_ns)
if isinstance(rows, dict):
result['rows'] = rows.get('rows', [])
result['total'] = rows.get('total', total)
elif rows:
result['rows'] = [dict(r) if hasattr(r, 'keys') else r for r in rows]
result['total'] = total
else:
result['total'] = 0
result['success'] = True
except Exception as e:
result['error'] = str(e)
return result

View File

@ -1,32 +0,0 @@
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
record_id = params_kw.get('id', '')
if not record_id:
result['options'] = {'title': 'Error', 'message': 'ID is required', 'type': 'error'}
else:
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
update_fields = []
update_ns = {'id': record_id}
for field in ['biz_type', 'biz_type_title', 'process_code', 'agent_id', 'form_config', 'is_active']:
val = params_kw.get(field)
if val is not None:
update_fields.append(f"{field}=${field}$")
update_ns[field] = val
# Always update updated_at
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
update_fields.append("updated_at=${updated_at}$")
update_ns['updated_at'] = now_str
if update_fields:
set_clause = ", ".join(update_fields)
await sor.sqlExe(f"UPDATE dd_approval_configs SET {set_clause} WHERE id=${id}$", update_ns)
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '审批配置更新成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'更新失败: {str(e)}', 'type': 'error'}
return result

View File

@ -1,39 +0,0 @@
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
biz_type = params_kw.get('biz_type', '')
biz_id = params_kw.get('biz_id', '')
title = params_kw.get('title', '')
applicant_id = params_kw.get('applicant_id', '')
if not biz_type or not title:
result['options'] = {'title': 'Error', 'message': 'biz_type and title are required', 'type': 'error'}
else:
new_id = getID()
org_id = (await get_userorgid()) or '0'
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe(
"INSERT INTO dd_approvals (id, org_id, biz_type, biz_id, title, applicant_id, approver_id, dingtalk_instance_id, status, comment, created_at) VALUES (${id}$, ${org_id}$, ${biz_type}$, ${biz_id}$, ${title}$, ${applicant_id}$, ${approver_id}$, ${dingtalk_instance_id}$, ${status}$, ${comment}$, ${created_at}$)",
{
'id': new_id,
'org_id': org_id,
'biz_type': biz_type,
'biz_id': biz_id,
'title': title,
'applicant_id': applicant_id,
'approver_id': params_kw.get('approver_id', ''),
'dingtalk_instance_id': params_kw.get('dingtalk_instance_id', ''),
'status': params_kw.get('status', 'pending'),
'comment': params_kw.get('comment', ''),
'created_at': now_str
}
)
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '审批记录创建成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'创建失败: {str(e)}', 'type': 'error'}
return result

View File

@ -1,16 +0,0 @@
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
record_id = params_kw.get('id', '')
if not record_id:
result['options'] = {'title': 'Error', 'message': 'ID is required', 'type': 'error'}
else:
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
await sor.sqlExe("DELETE FROM dd_approvals WHERE id=${id}$", {'id': record_id})
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '审批记录删除成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'删除失败: {str(e)}', 'type': 'error'}
return result

View File

@ -1,72 +0,0 @@
result = {'success': False, 'rows': [], 'total': 0}
try:
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
# Build WHERE clause from data_filter or direct params
where_clauses = []
where_ns = {}
# Support data_filter from CRUD search popup
data_filter_str = params_kw.get('data_filter', '')
if data_filter_str:
# Individual filter values passed alongside data_filter
status_filter = params_kw.get('status_filter', '')
biz_type_filter = params_kw.get('biz_type_filter', '')
title_filter = params_kw.get('title_filter', '')
if status_filter:
where_clauses.append("status=${status_filter}$")
where_ns['status_filter'] = status_filter
if biz_type_filter:
where_clauses.append("biz_type=${biz_type_filter}$")
where_ns['biz_type_filter'] = biz_type_filter
if title_filter:
where_clauses.append("title LIKE ${title_filter}$")
where_ns['title_filter'] = f'%{title_filter}%'
else:
# Direct param filtering
status = params_kw.get('status', '')
if status:
where_clauses.append("status=${status}$")
where_ns['status'] = status
biz_type = params_kw.get('biz_type', '')
if biz_type:
where_clauses.append("biz_type=${biz_type}$")
where_ns['biz_type'] = biz_type
where_sql = " AND ".join(where_clauses)
where_prefix = " WHERE " if where_clauses else ""
# Count query
count_sql = "SELECT count(*) rcnt FROM dd_approvals" + where_prefix + where_sql
count_rows = await sor.sqlExe(count_sql, where_ns)
total = 0
if count_rows and len(count_rows) > 0:
r = count_rows[0]
total = getattr(r, 'rcnt', 0)
if total > 0:
ns = {
'page': int(params_kw.get('page', 1)),
'rows': int(params_kw.get('rows', 20)),
'sort': params_kw.get('sort', 'created_at desc')
}
sql = "SELECT id, org_id, biz_type, biz_id, title, applicant_id, approver_id, dingtalk_instance_id, status, comment, created_at, completed_at FROM dd_approvals" + where_prefix + where_sql
query_ns = dict(list(ns.items()) + list(where_ns.items()))
rows = await sor.sqlExe(sql, query_ns)
if isinstance(rows, dict):
result['rows'] = rows.get('rows', [])
result['total'] = rows.get('total', total)
elif rows:
result['rows'] = [dict(r) if hasattr(r, 'keys') else r for r in rows]
result['total'] = total
else:
result['total'] = 0
result['success'] = True
except Exception as e:
result['error'] = str(e)
return result

View File

@ -1,32 +0,0 @@
result = {'widgettype': 'Message', 'options': {'title': 'Error', 'message': 'Invalid request', 'type': 'error'}}
try:
record_id = params_kw.get('id', '')
if not record_id:
result['options'] = {'title': 'Error', 'message': 'ID is required', 'type': 'error'}
else:
dbname = get_module_dbname('dingdingflow')
async with DBPools().sqlorContext(dbname) as sor:
update_fields = []
update_ns = {'id': record_id}
for field in ['biz_type', 'biz_id', 'title', 'applicant_id', 'approver_id', 'dingtalk_instance_id', 'status', 'comment']:
val = params_kw.get(field)
if val is not None:
update_fields.append(f"{field}=${field}$")
update_ns[field] = val
completed_at = params_kw.get('completed_at')
if completed_at:
update_fields.append("completed_at=${completed_at}$")
update_ns['completed_at'] = completed_at
if update_fields:
set_clause = ", ".join(update_fields)
await sor.sqlExe(f"UPDATE dd_approvals SET {set_clause} WHERE id=${id}$", update_ns)
result = {'widgettype': 'Message', 'options': {'title': 'Success', 'message': '审批记录更新成功', 'type': 'success'}}
except Exception as e:
result['options'] = {'title': 'Error', 'message': f'更新失败: {str(e)}', 'type': 'error'}
return result

View File

@ -1,45 +0,0 @@
result = {'success': False, 'message': 'Invalid callback'}
try:
# DingTalk callback data comes via params_kw (POST body auto-parsed)
callback_data = {}
# params_kw contains the parsed POST body fields
process_instance_id = params_kw.get('processInstanceId', '')
callback_type = params_kw.get('type', '')
callback_result = params_kw.get('result', '')
staff_id = params_kw.get('staffId', '')
process_code = params_kw.get('processCode', '')
remark = params_kw.get('remark', '')
# Also handle nested JSON body case where entire body is under a key
if not process_instance_id:
body = params_kw.get('body', None)
if isinstance(body, dict):
process_instance_id = body.get('processInstanceId', '')
callback_type = body.get('type', '')
callback_result = body.get('result', '')
staff_id = body.get('staffId', '')
process_code = body.get('processCode', '')
remark = body.get('remark', '')
callback_data = {
'processInstanceId': process_instance_id,
'type': callback_type,
'result': callback_result,
'staffId': staff_id,
'processCode': process_code,
'remark': remark,
}
if not process_instance_id:
result = {'success': False, 'message': 'Missing processInstanceId'}
else:
# Call the handle_dingtalk_callback function registered via load_dingdingflow()
callback_result_data = await handle_dingtalk_callback(callback_data)
result = callback_result_data
except Exception as e:
result = {'success': False, 'message': f'Callback processing error: {str(e)}'}
return result