# -*- coding: utf-8 -*- from pathlib import Path import math from PIL import Image, ImageDraw, ImageFont BASE_DIR = Path(__file__).resolve().parent FONT_CANDIDATES = [ r"C:\Windows\Fonts\simhei.ttf", r"C:\Windows\Fonts\Dengb.ttf", r"C:\Windows\Fonts\NotoSansSC-VF.ttf", r"C:\Windows\Fonts\Deng.ttf", r"C:\Windows\Fonts\simsun.ttc", ] def make_canvas(width, height): img = Image.new("RGB", (width, height), "#F7F9FC") draw = ImageDraw.Draw(img) return img, draw def load_font(size, bold=False): candidates = [] if bold: candidates.extend([r"C:\Windows\Fonts\Dengb.ttf", r"C:\Windows\Fonts\simhei.ttf"]) candidates.extend(FONT_CANDIDATES) for path in candidates: try: return ImageFont.truetype(path, size=size) except Exception: pass return ImageFont.load_default() class Diagram: def __init__(self, width, height): self.width = width self.height = height self.img, self.draw = make_canvas(width, height) self.font_title = load_font(72, True) self.font_subtitle = load_font(34) self.font_section = load_font(40, True) self.font_box = load_font(33) self.font_small = load_font(29) self.font_note = load_font(31) self.font_edge = load_font(24) def text_size(self, text, font): bbox = self.draw.textbbox((0, 0), text, font=font) return bbox[2] - bbox[0], bbox[3] - bbox[1] def wrap_line(self, line, font, max_width): if not line: return [""] lines = [] current = "" for ch in line: test = current + ch if self.text_size(test, font)[0] <= max_width or not current: current = test else: lines.append(current) current = ch if current: lines.append(current) return lines def wrap_text(self, text, font, max_width): lines = [] for raw in text.split("\n"): lines.extend(self.wrap_line(raw, font, max_width)) return lines def text_center(self, text, cx, cy, max_width, font=None, fill="#172033", line_gap=10): font = font or self.font_box lines = self.wrap_text(text, font, max_width) heights = [self.text_size(line, font)[1] for line in lines] total_h = sum(heights) + line_gap * (len(lines) - 1) y = cy - total_h / 2 for line, h in zip(lines, heights): w, _ = self.text_size(line, font) self.draw.text((cx - w / 2, y), line, font=font, fill=fill) y += h + line_gap def text_left(self, text, x, y, max_width, font=None, fill="#344054", line_gap=10): font = font or self.font_note yy = y for line in self.wrap_text(text, font, max_width): self.draw.text((x, yy), line, font=font, fill=fill) yy += self.text_size(line, font)[1] + line_gap return yy def title(self, title, subtitle): tw, _ = self.text_size(title, self.font_title) self.draw.text(((self.width - tw) / 2, 50), title, font=self.font_title, fill="#111827") sw, _ = self.text_size(subtitle, self.font_subtitle) self.draw.text(((self.width - sw) / 2, 138), subtitle, font=self.font_subtitle, fill="#475467") def box(self, cx, cy, w, h, text, fill="#FFFFFF", outline="#2E5A8A", radius=28, width=5, font=None): font = font or self.font_box bounds = (cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2) self.draw.rounded_rectangle(bounds, radius=radius, fill=fill, outline=outline, width=width) self.text_center(text, cx, cy, w - 60, font=font) return bounds def panel(self, x, y, w, h, title, fill="#FFFFFF", outline="#C7D2E5"): self.draw.rounded_rectangle((x, y, x + w, y + h), radius=34, fill=fill, outline=outline, width=5) self.draw.text((x + 38, y + 26), title, font=self.font_section, fill="#172033") def diamond(self, cx, cy, w, h, text, fill="#FFF4CC", outline="#B27700"): points = [(cx, cy - h / 2), (cx + w / 2, cy), (cx, cy + h / 2), (cx - w / 2, cy)] self.draw.polygon(points, fill=fill, outline=outline) self.draw.line(points + [points[0]], fill=outline, width=5) self.text_center(text, cx, cy, w - 70, font=self.font_box) return points def arrow(self, points, color="#344054", width=6, label=None, label_pos=None): for p1, p2 in zip(points, points[1:]): self.draw.line((p1[0], p1[1], p2[0], p2[1]), fill=color, width=width) x1, y1 = points[-2] x2, y2 = points[-1] angle = math.atan2(y2 - y1, x2 - x1) size = 24 left = (x2 - size * math.cos(angle - math.pi / 7), y2 - size * math.sin(angle - math.pi / 7)) right = (x2 - size * math.cos(angle + math.pi / 7), y2 - size * math.sin(angle + math.pi / 7)) self.draw.polygon([(x2, y2), left, right], fill=color) if label: lx, ly = label_pos if label_pos else ((x1 + x2) / 2, (y1 + y2) / 2) tw, th = self.text_size(label, self.font_edge) self.draw.rounded_rectangle( (lx - tw / 2 - 12, ly - th / 2 - 7, lx + tw / 2 + 12, ly + th / 2 + 7), radius=10, fill="#F7F9FC", ) self.draw.text((lx - tw / 2, ly - th / 2), label, font=self.font_edge, fill=color) def note_box(self, x, y, w, h, text, fill="#FFF1F3", outline="#C01048"): self.draw.rounded_rectangle((x, y, x + w, y + h), radius=28, fill=fill, outline=outline, width=5) self.text_center(text, x + w / 2, y + h / 2, w - 90, font=self.font_note, fill="#7A271A") def footer(self, text): tw, _ = self.text_size(text, self.font_edge) self.draw.text(((self.width - tw) / 2, self.height - 52), text, font=self.font_edge, fill="#667085") def save(self, name): path = BASE_DIR / name self.img.save(path, format="PNG", dpi=(220, 220), optimize=True) print(f"{path} {self.width}x{self.height}") def draw_main(): d = Diagram(5400, 3400) d.title("MESCommonBase 响应输出与 SqlWebCall 主流程图", "主图:入口 type 分支如何进入二进制下载、JSON、IP 文本和 SqlWebCall") cx = d.width / 2 d.box(cx, 290, 1050, 125, "MESCommonBase.ashx\nProcessRequest", "#E8F1FF", "#2864B4") d.box(cx, 480, 1400, 140, "解析请求并取得 type\nJSON body / jsonobj / Request[\"param\"]", "#EAF7EF", "#227A45") d.diamond(cx, 690, 480, 170, "switch\n(type)") d.arrow([(cx, 352), (cx, 410)]) d.arrow([(cx, 550), (cx, 607)]) panel_x, panel_y, panel_w, panel_h = 190, 860, 5020, 1120 d.panel(panel_x, panel_y, panel_w, panel_h, "1. Type 首层分支") d.arrow([(cx, 775), (cx, panel_y + 5)]) lanes = [ (830, "2001/2002/2003/2004/16\n文件下载类分支\n生成或读取 bytes / fileName / extension", "#EAF7EF", "#227A45"), (2040, "15\n文件上传分支\n读取 Request.Files[0]\n调用 ExePROCEDURE_Type15", "#EEF2FF", "#4F46E5"), (3250, "4000\nIP 查询分支\n读取 ServerVariables / UserHostAddress", "#FFF7E6", "#B27700"), (4460, "default\n普通业务分支\nDataLink.SqlWebCall(type,jsonData,dataobj)", "#F3E8FF", "#7E22CE"), ] for x, text, fill, outline in lanes: d.box(x, 1110, 1080, 250, text, fill, outline, font=d.font_small) d.diamond(830, 1450, 430, 150, "bytes\n有效?", "#FFF4CC", "#B27700") d.box(830, 1760, 1080, 250, "二进制下载响应\napplication/octet-stream\nContent-Disposition: attachment\nBinaryWrite + Flush + End/Close", "#EAF7EF", "#227A45", font=d.font_small) d.box(1320, 1545, 480, 130, "无文件\n直接 return\n可能空响应", "#FFF1F3", "#C01048", font=d.font_small) d.arrow([(830, 1235), (830, 1375)]) d.arrow([(830, 1525), (830, 1635)], label="是", label_pos=(780, 1580)) d.arrow([(1045, 1450), (1165, 1450), (1165, 1545), (1080, 1545)], label="否", label_pos=(1145, 1408)) d.box(2040, 1760, 1080, 250, "JSON 文本响应\nResponse.Write(responseText)\n常见 result=1 / result=0", "#EEF2FF", "#4F46E5", font=d.font_small) d.arrow([(2040, 1235), (2040, 1635)]) d.box(3250, 1760, 1080, 250, "文本 IP 响应\nResponse.Write(userIP)\nContent-Type 仍可能是 application/json", "#FFF7E6", "#B27700", font=d.font_small) d.arrow([(3250, 1235), (3250, 1635)]) d.box(4460, 1450, 1080, 220, "SqlWebCall 返回字符串\n数组 JSON / code-message-data\n未支持 Type 可能为空字符串", "#F3E8FF", "#7E22CE", font=d.font_small) d.arrow([(4460, 1235), (4460, 1340)]) d.box(4460, 1760, 1080, 250, "JSON 文本响应\nHeaders.Remove(\"Server\")\nResponse.Write(responseText)", "#EEF2FF", "#4F46E5", font=d.font_small) d.arrow([(4460, 1560), (4460, 1635)]) d.panel(360, 2140, 2280, 660, "2. 响应出口规则") d.text_left( "文件下载:设置 application/octet-stream,写 Content-Disposition,BinaryWrite(bytes)。\n\n" "上传/default:使用 Response.Write 写 JSON 字符串,格式由下游决定。\n\n" "IP 查询:直接写 userIP,实际是文本。\n\n" "Type=16 bytes 为 null 时直接 return,调用方可能收到空响应。", 430, 2245, 2140, font=d.font_note, ) d.panel(2860, 2140, 2180, 660, "3. 外层异常兜底") d.text_left( "responseText 初始值为 NULL。\n\n" "ProcessRequest 外层 catch(Exception err) 不记录日志、不改 HTTP 状态码,只写当前 responseText。\n\n" "如果异常发生在下游调用前,通常返回 NULL;如果发生在赋值后,可能返回旧结果。", 2930, 2245, 2040, font=d.font_note, ) d.note_box( 680, 2940, 4040, 150, "主流程风险:响应格式混合(二进制/JSON/文本/NULL/空响应),Response.End 位于 try 内,default 分支执行能力取决于 SqlWebCall 内部 Type。", ) d.box(d.width - 520, 3180, 420, 92, "请求结束", "#E8F1FF", "#2864B4", radius=45, font=d.font_small) d.arrow([(d.width / 2, 3090), (d.width - 730, 3180)]) d.footer("源:MESCommonBase响应输出与SqlWebCall主流程图.mmd 图:MESCommonBase响应输出与SqlWebCall主流程图.png") d.save("MESCommonBase响应输出与SqlWebCall主流程图.png") def draw_secondary(): d = Diagram(5800, 4300) d.title("DataLink.SqlWebCall 默认分支次流程图", "次图:SqlWebCall 内部初始化、Type 二次分发、参数解析、数据库执行和返回格式") cx = d.width / 2 d.box(cx, 285, 1260, 120, "MESCommonBase default 分支\nDataLink.SqlWebCall(type,jsonData,dataobj)", "#F3E8FF", "#7E22CE") d.diamond(cx, 500, 520, 160, "initSystemIsOk?", "#FFF4CC", "#B27700") d.box(4150, 500, 1040, 140, "InitSystemReg\n读取 Web.config ConnectionString\ninitSystemIsOk = true", "#E8F1FF", "#2864B4", font=d.font_small) d.diamond(cx, 735, 500, 160, "switch\n(type)", "#FFF4CC", "#B27700") d.arrow([(cx, 345), (cx, 420)]) d.arrow([(cx + 260, 500), (3630, 500)], label="否", label_pos=(3430, 458)) d.arrow([(4150, 570), (4150, 735), (3150, 735)]) d.arrow([(cx, 580), (cx, 655)], label="是", label_pos=(2820, 625)) panel_y = 930 d.panel(150, panel_y, 5500, 1630, "1. Type 二次分发与处理") cols = [ (860, "登录/加密类\n8888 / 5001 / 5002", "#E8F1FF", "#2864B4"), (2180, "旧协议存储过程\n1 / 2 / 5", "#EEF2FF", "#4F46E5"), (3500, "新协议存储过程\n11 / 111 / 12 / 13 / 21", "#EAF7EF", "#227A45"), (4820, "SQL/命令执行类\n1001 / 1002 / 3 / 4 / 7 / 22 / 3001", "#FFF7E6", "#B27700"), ] for x, header, fill, outline in cols: d.box(x, 1140, 1180, 180, header, fill, outline, font=d.font_small) d.box(860, 1420, 1180, 290, "8888:读取 Name/name 并加密\n5001:Password MD5 后执行存储过程\n5002:解密密码、比对数据库密码、成功生成 token", "#E8F1FF", "#2864B4", font=d.font_small) d.box(860, 1805, 1180, 300, "返回格式\n加密字符串\n用户表 JSON + token\n失败 result=0,msg=...", "#FFFFFF", "#2864B4", font=d.font_small) d.arrow([(860, 1230), (860, 1275)]) d.arrow([(860, 1565), (860, 1655)]) d.box(2180, 1420, 1180, 290, "输入对象:jsonobj dataobj\nParam 拼接字符串\nType 1/2: @p=value=type\nType 5: @p&value&type|...", "#EEF2FF", "#4F46E5", font=d.font_small) d.box(2180, 1805, 1180, 300, "SQLCommon.GetCmdParam\n按 & / = / | 拆参数\n支持 output 参数\n随后 ExecuteStoredProcedure", "#FFFFFF", "#4F46E5", font=d.font_small) d.arrow([(2180, 1230), (2180, 1275)]) d.arrow([(2180, 1565), (2180, 1655)]) d.box(3500, 1420, 1180, 290, "GetString_JsonData\n读取 Type/Name/Param/token\nParam 是 JSON 数组字符串\n兼容大小写字段", "#EAF7EF", "#227A45", font=d.font_small) d.diamond(3500, 1785, 440, 150, "token\n非空?", "#FFF4CC", "#B27700") d.box(3030, 2045, 470, 150, "CheckToken\n失败返回\nresult=3", "#FFF1F3", "#C01048", font=d.font_small) d.box(3970, 2045, 650, 150, "Param 转 SqlParameter[]\n数组值转逗号字符串\noutput=1 设输出参数", "#EAF7EF", "#227A45", font=d.font_small) d.arrow([(3500, 1230), (3500, 1275)]) d.arrow([(3500, 1565), (3500, 1710)]) d.arrow([(3280, 1785), (3030, 1970)], label="是", label_pos=(3195, 1890)) d.arrow([(3720, 1785), (3970, 1970)], label="否/通过", label_pos=(3880, 1890)) d.box(4820, 1420, 1180, 290, "Name/name 可能是 SQL 文本或表名\n1001/1002:带参数 SQL\n3/4/22:直接 SQL\n7:DROP/CREATE 表并导入", "#FFF7E6", "#B27700", font=d.font_small) d.box(4820, 1805, 1180, 300, "SQLCommon.ExecuteDataTable / ExecuteDataset\nExecuteInsertMesWork / ExecuteSelectMesWork\n或 DbCallType1003_SqlCmd.SqlExec", "#FFFFFF", "#B27700", font=d.font_small) d.arrow([(4820, 1230), (4820, 1275)]) d.arrow([(4820, 1565), (4820, 1655)]) d.panel(300, 2740, 2440, 780, "2. 底层数据库执行链路") d.text_left( "存储过程:SQLCommon.ExecuteStoredProcedure\n- SqlCommand.CommandType = StoredProcedure\n- CommandTimeout = 0\n- SqlDataAdapter.Fill(DataSet/DataTable)\n\n" "SQL 查询:ExecuteSelectMesWork / ExecuteDataTable / ExecuteDataset\n- SqlDataAdapter(sql, conn)\n- Fill(dt/ds)\n\n" "SQL 增删改:ExecuteInsertMesWork / ExecuteNonQuery\n- SqlCommand.CommandText = 请求传入 SQL\n- ExecuteNonQuery / ExecuteScalar", 380, 2845, 2290, font=d.font_note, ) d.panel(3060, 2740, 2440, 780, "3. 返回格式与响应出口") d.text_left( "DataTable JSON:Type 1/3/5/11/1002 常见。\n\n" "result 标志:Type 2/4/12/1001 常见,成功 result=1,失败 result=0。\n\n" "分页:Type 111 返回 { rows, total },total 来自 ItemCount 输出参数。\n\n" "新结构:Type 21/22 返回 { code, message, data }。\n\n" "未支持 Type:SqlWebCall 可能返回空字符串。", 3140, 2845, 2290, font=d.font_note, ) d.note_box( 560, 3700, 4680, 210, "次流程风险:token 多数是非空才校验;客户端可控制 Name/name;SQL 文本、存储过程名、表名缺少统一白名单;CommandTimeout=0 可能导致长时间阻塞。", ) d.box(d.width - 620, 4080, 560, 95, "返回 MESCommonBase\nResponse.Write", "#E8F1FF", "#2864B4", radius=45, font=d.font_small) d.arrow([(d.width / 2, 3910), (d.width - 900, 4080)]) d.footer("源:MESCommonBase响应输出与SqlWebCall次流程图.mmd 图:MESCommonBase响应输出与SqlWebCall次流程图.png") d.save("MESCommonBase响应输出与SqlWebCall次流程图.png") if __name__ == "__main__": draw_main() draw_secondary()