yumoqing f57bb0b0af fix: CMS completeness修复 — products.ui + 钉钉回调同步 + 文档路径
- P0: 新增 products.ui 产品架构页面
- P1: 实现钉钉审批回调→cms_content状态同步(approved→published, rejected→draft)
- P2: 修复 docs/architecture.md 路径(/entcms/* → /*),补充cms_sections表
- P2: 重命名 init_superuser.py → init_superuser_permissions.py 避免与scripts/冲突
- build.sh 更新构建后步骤说明
2026-06-03 16:16:05 +08:00

437 lines
15 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
dingdingflow module initialization.
Registers all module functions with ServerEnv for use in .ui and .dspy files.
"""
import json
import logging
import datetime
from ahserver.serverenv import ServerEnv
from appPublic.uniqueID import getID
from dingdingflow.dingtalk_client import get_dingtalk_client
logger = logging.getLogger(__name__)
MODULE_NAME = "dingdingflow"
MODULE_VERSION = "1.0.0"
def _get_dbname():
"""Get the database name for this module."""
env = ServerEnv()
return env.get_module_dbname(MODULE_NAME)
# ─── CRUD: dd_approvals ───────────────────────────────────────────────────────
async def create_dd_approval(data):
"""Create a new approval record."""
new_id = getID()
data["id"] = new_id
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")
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
await sor.C("dd_approvals", data)
return {"id": new_id}
async def update_dd_approval(data):
"""Update an existing approval record."""
record_id = data.get("id")
if not record_id:
raise ValueError("id is required for update")
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
await sor.U("dd_approvals", {"id": record_id}, data)
return {"id": record_id}
async def delete_dd_approval(record_id):
"""Delete an approval record by ID."""
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
await sor.D("dd_approvals", {"id": record_id})
return True
async def get_dd_approval(record_id):
"""Get a single approval record by ID."""
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
rows = await sor.R("dd_approvals", {"id": record_id})
if rows:
return rows[0]
return None
async def list_dd_approvals(filters=None, page=1, rows=20, sort="created_at desc"):
"""List approval records with optional filters."""
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
query_filters = filters or {}
ns = {"page": page, "rows": rows, "sort": sort}
ns.update(query_filters)
result = await sor.R("dd_approvals", query_filters, page=page, rows=rows, sort=sort)
return result
# ─── CRUD: dd_approval_configs ────────────────────────────────────────────────
async def create_dd_approval_config(data):
"""Create a new approval config record."""
new_id = getID()
data["id"] = new_id
if "org_id" not in data:
data["org_id"] = "0"
if "is_active" not in data:
data["is_active"] = "1"
if "created_at" not in data:
data["created_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
data["updated_at"] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
await sor.C("dd_approval_configs", data)
return {"id": new_id}
async def update_dd_approval_config(data):
"""Update an existing approval config record."""
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")
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
await sor.U("dd_approval_configs", {"id": record_id}, data)
return {"id": record_id}
async def delete_dd_approval_config(record_id):
"""Delete an approval config record by ID."""
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
await sor.D("dd_approval_configs", {"id": record_id})
return True
async def get_dd_approval_config(record_id):
"""Get a single approval config record by ID."""
dbname = _get_dbname()
db = ServerEnv().db
async with db.sqlorContext(dbname) as sor:
rows = await sor.R("dd_approval_configs", {"id": record_id})
if rows:
return rows[0]
return None
async def get_approval_config_by_type(org_id, biz_type):
"""Get approval config by org_id and biz_type (unique constraint)."""
dbname = _get_dbname()
db = ServerEnv().db
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
# ─── Business Logic: Approval Workflow ────────────────────────────────────────
async def submit_approval(biz_type, biz_id, title, applicant_id, org_id="0"):
"""
Submit a new approval request.
1. Look up the approval config for this biz_type
2. Create a dd_approvals record
3. Call DingTalk API to create the approval instance
4. Store the DingTalk instance_id back in the record
Returns: dict with approval record details
"""
client = get_dingtalk_client()
# Look up config
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 = getattr(config, "process_code", "") or ""
agent_id = getattr(config, "agent_id", "") or ""
form_config_raw = getattr(config, "form_config", "") or ""
# Build form data from 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 no form_data, create minimal form with title
if not form_data:
form_data = [
{"name": "审批标题", "value": title},
{"name": "业务类型", "value": biz_type},
]
# Call DingTalk API
result = client.create_approval_instance(process_code, form_data, applicant_id)
if not result["success"]:
# Still create the record with failed status
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 create_dd_approval(approval_data)
return {
"success": False,
"message": f"DingTalk API failed: {result.get('errmsg', '')}",
"approval_id": approval["id"],
}
# Create approval record with instance_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 create_dd_approval(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):
"""
Query DingTalk for the latest approval status and sync to local DB.
Returns: dict with current status info
"""
# Get local record
record = await get_dd_approval(approval_id)
if not record:
return {"success": False, "message": "Approval record not found"}
instance_id = getattr(record, "dingtalk_instance_id", "")
current_status = getattr(record, "status", "")
# If already completed, no need to check DingTalk
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",
}
# Query DingTalk
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,
}
# Map DingTalk status to local 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"
# Update local record if status changed
if new_status != current_status:
update_data = {"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 update_dd_approval({"id": approval_id, **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):
"""
Process DingTalk webhook callback.
DingTalk sends callbacks when approval status changes.
Expected data format:
{
"processInstanceId": "xxx",
"processCode": "xxx",
"type": "bpms_instance_change",
"result": "agree" / "refuse",
"staffId": "xxx",
...
}
"""
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}"}
# Find local approval record by DingTalk instance ID
dbname = _get_dbname()
db = ServerEnv().db
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 = getattr(record, "id", "")
current_status = getattr(record, "status", "")
# Map callback to 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"
# Update record
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 update_dd_approval(update_data)
logger.info("Callback: approval %s updated to %s", record_id, new_status)
# Notify entcms module about status change
biz_type = getattr(record, "biz_type", "")
biz_id = 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_dingdingflow():
"""Register all dingdingflow functions with ServerEnv."""
env = ServerEnv()
# CRUD functions for dd_approvals
env.create_dd_approval = create_dd_approval
env.update_dd_approval = update_dd_approval
env.delete_dd_approval = delete_dd_approval
env.get_dd_approval = get_dd_approval
env.list_dd_approvals = list_dd_approvals
# CRUD functions for dd_approval_configs
env.create_dd_approval_config = create_dd_approval_config
env.update_dd_approval_config = update_dd_approval_config
env.delete_dd_approval_config = delete_dd_approval_config
env.get_dd_approval_config = get_dd_approval_config
env.get_approval_config_by_type = get_approval_config_by_type
# Business logic functions
env.submit_approval = submit_approval
env.get_approval_status = get_approval_status
env.handle_dingtalk_callback = handle_dingtalk_callback
# DingTalk client accessor
env.get_dingtalk_client = get_dingtalk_client
logger.info("dingdingflow module loaded (v%s)", MODULE_VERSION)
return True