yjbdcController.js 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958
  1. import moment from 'moment';
  2. import fs from 'fs';
  3. import commonModel from '../../model/commonModel.js';
  4. import config from '../../config/index.js';
  5. import _ from 'lodash';
  6. import axios from 'axios';
  7. import { Encrypt, Decrypt } from '../../util/crypto/index.js';
  8. import { stringUtils } from '../../util/stringClass.js';
  9. import WXBizDataCrypt from '../../util/WXBizDataCrypt.js';
  10. import { globalCache } from '../../util/GlobalCache.js';
  11. import yjbdc from '../../model/yjbdc.js';
  12. import aiController from './aiController.js';
  13. import PDFDocument from 'pdfkit';
  14. import tencentcloud from 'tencentcloud-sdk-nodejs-ocr';
  15. const ONE_DAY_MAX_BUILD_COUNT=12;//一天最大生成数
  16. const OcrClient = tencentcloud.ocr.v20181119.Client;
  17. // AI平台配置
  18. const DEFAULT_AI_PROVIDER = 'volces1-5'; // 默认使用火山云AI
  19. export async function YJBDCLogin(ctx) {
  20. let param = ctx.request.body;
  21. if (param.param) {
  22. const paramStr = Decrypt(param.param, config.urlSecrets.aes_key, config.urlSecrets.aes_iv);
  23. //console.log("paramStr:"+paramStr);
  24. param = JSON.parse(paramStr);
  25. }
  26. const code = param.Code;
  27. //console.log("code:"+code);
  28. const url = `https://api.weixin.qq.com/sns/jscode2session?appid=${config.wx.yjbdc_appid}&secret=${config.wx.yjbdc_appsecret}&js_code=${code}&grant_type=authorization_code`;
  29. let result = await axios.get(url)
  30. .then(res => {
  31. const json = res.data;
  32. //console.log("json:"+json);
  33. if (json && json.openid) {
  34. param.OpenID = json.openid;
  35. param.sessionKey = json.session_key;
  36. if (json.unionid)
  37. param.UnionID = json.unionid;
  38. return {errcode: 10000};
  39. }
  40. else {
  41. return json;
  42. }
  43. })
  44. .catch(err => {
  45. return {errcode: 101, errStr: err};
  46. });
  47. if (result.errcode == 10000) {
  48. delete param.Code;
  49. if (param.sessionKey && param.iv && param.encryptedData){
  50. //console.log("param.sessionKey:"+param.sessionKey);
  51. const pc = new WXBizDataCrypt(config.wx.yjbdc_appid, param.sessionKey);
  52. const dataUnionID = pc.decryptData(param.encryptedData , param.iv);
  53. //console.log(dataUnionID);
  54. param.UnionID = dataUnionID.unionId;
  55. }
  56. delete param.sessionKey;
  57. delete param.iv;
  58. delete param.encryptedData;
  59. //todo
  60. //param.OpenID="o4UHq4gaNlHfdTWxgl3fTgC1mFsI";
  61. let userList = await yjbdc.GetUsersInfo(param);
  62. if (userList.length > 0) {
  63. param.LastLoginTime = new Date();
  64. const time1 = moment(userList[0].ProductServiceTime).format('YYYY-MM-DD HH:mm:ss');
  65. const time3 = moment().format('YYYY-MM-DD HH:mm:ss');
  66. if (time1 < time3)
  67. param.IsMember = 0;
  68. delete param.Introducer;
  69. delete param.UserSource;
  70. delete param.SourceID;
  71. //console.log(param.NickName);
  72. if (param.NickName == "陌生用户") {
  73. delete param.NickName;
  74. delete param.AvatarUrl;
  75. delete param.Language;
  76. delete param.Gender;
  77. delete param.City;
  78. delete param.Province;
  79. delete param.Country;
  80. }
  81. await yjbdc.UpdateUsers(param);
  82. userList = await yjbdc.GetUsersInfo(param);
  83. }
  84. else {
  85. param.NickName = "陌生用户";
  86. param.AvatarUrl = "../images/userface_default.png";
  87. param.CreateTime = new Date();
  88. param.LastLoginTime = param.CreateTime;
  89. param.ProductServiceTime = param.CreateTime;
  90. const inseredID = await yjbdc.AddUsers(param);
  91. userList = await yjbdc.GetUsersInfo(param);
  92. }
  93. delete userList[0].OpenID;
  94. delete userList[0].UnionID;
  95. //产品支付是否显示
  96. if (param.ProgramVersion) {
  97. let param2 = {
  98. ProgramID: 186,
  99. Version: param.ProgramVersion,
  100. };
  101. let result3 = await commonModel.GetProductVersionList(param2);
  102. if (result3) {
  103. if ((param2.Version == result3[0].Version && result3[0].IsShowPay <= 0)
  104. || param2.Version > result3[0].Version) {
  105. userList[0].IsShow = result3[0].IsShowPay;
  106. }
  107. else {
  108. userList[0].IsShow = 1;
  109. }
  110. //针对iphone测试用户,永远是无支付状态
  111. if (userList[0].Brand == 'iPhone' && userList[0].WXLanguage == 'en-US'
  112. && userList[0].UserSource == '1001' && userList[0].IsPay == 0) {
  113. userList[0].IsShow = 0;
  114. }
  115. //针对微信测试用户,永远是无支付状态
  116. if ((userList[0].UserSource=='1001' && userList[0].System=="iOS 10.0.1")
  117. || (!userList[0].UserSource && (!userList[0].LastUserSource || userList[0].LastUserSource>10000))
  118. || userList[0].NickName.indexOf("dgztest")>=0){
  119. userList[0].IsShow=-1;
  120. }
  121. if (userList[0].IsMember===1)
  122. userList[0].IsShow=1;
  123. }
  124. }
  125. result = {errcode: 10000, result: userList[0]};
  126. }
  127. ctx.body = result;
  128. }
  129. export async function OCRImageData(ctx) {
  130. const params = ctx.request.body;
  131. const clientConfig = {
  132. credential: {
  133. secretId: config.tencentcloud.secretId,
  134. secretKey: config.tencentcloud.secretKey,
  135. },
  136. region: "ap-guangzhou",
  137. profile: {
  138. httpProfile: {
  139. endpoint: "ocr.tencentcloudapi.com",
  140. },
  141. },
  142. };
  143. // 实例化要请求产品的client对象,clientProfile是可选的
  144. const client = new OcrClient(clientConfig);
  145. const result = await client.GeneralBasicOCR(params);
  146. let param2={};
  147. param2.UserID=ctx.query.UserID;
  148. param2.CreateTime=moment().format('YYYY-MM-DD HH:mm:ss');
  149. param2.Params=JSON.stringify(params);
  150. param2.JSONString=JSON.stringify(result);
  151. await yjbdc.AddOCRInfo(param2);
  152. ctx.body = {"errcode": 10000, result};
  153. }
  154. export async function GenerateArticle(ctx) {
  155. const url='GenerateArticle?UserID='+ctx.query.UserID;
  156. let result = globalCache.get(url);
  157. if (result === 0) {
  158. const params = ctx.request.body;
  159. const words = params.Words;
  160. let articleStyle = params.ArticleStyle;
  161. //文章类型
  162. const ARTICLE_STYLE={
  163. "成长":"指一个人从小慢慢长大,不仅身体变高变壮,还学会更多知识,懂得更多道理,变得越来越懂事、能干。",
  164. "童话":"充满神奇和想象的故事,里面可能有会说话的动物、美丽的公主、勇敢的王子,还有魔法和冒险,比如《白雪公主》、《小红帽》。",
  165. "家庭亲子":"指爸爸妈妈和孩子之间的相处,比如一起玩游戏、读书、聊天,让家庭充满爱和温暖。",
  166. "人生励志":"讲述一个人如何克服困难、努力奋斗,最终取得成功的故事,鼓励我们不要轻易放弃,比如《爱迪生发明电灯》。",
  167. "科幻":"科学幻想故事,里面有未来科技、外星人、太空旅行等,比如《海底两万里》、《流浪地球》、《时间的折皱》。",
  168. "奇幻":"充满魔法、神奇生物和不可思议事件的故事,比如《哈利·波特》。",
  169. "校园生活":"发生在学校里的故事,比如上课、考试、运动会、和同学一起玩耍,与友情、团结、如何学习、克服困难有关的事情,也可能有搞笑或感人的事情。",
  170. "节日文化":"不同节日的习俗和传统,比如春节贴春联、中秋节吃月饼、端午划龙舟、清明节扫墓、重阳节登高望远、元宵节猜灯谜,让我们了解不同文化。",
  171. "旅行":"去不同的地方游玩,看看美丽的风景,体验不一样的生活,比如爬山、逛动物园、参观博物馆。",
  172. "科普":"科学普及知识,用有趣的方式讲解自然、宇宙、动物、植物等科学现象,比如《十万个为什么》。",
  173. "动物":"生活在地球上的各种生物,有的会跑,有的会飞,有的会游泳,比如猫、狗、大象、企鹅,它们都有自己的特点和习性。",
  174. "环保":"指环境保护、保护大自然,让地球更干净、更健康。比如节约用水、减少垃圾、种树、不乱扔塑料袋,这样空气会更清新,动物也有更好的家园。每个人都可以从小事做起爱护地球。",
  175. };
  176. //等级难度
  177. const LEVEL=[
  178. {
  179. Key:"小学",
  180. Content:"难度上是中国的小学六年级毕业的孩子可以阅读,生成的文章中除了用户提供的单词,其他全部使用人教版小学英语词汇表的单词,以及Sight words中220个单词,不要超出范围。"
  181. },
  182. {
  183. Key:"初中",
  184. Content:"难度上是中国的初三毕业的孩子可以阅读,生成的文章中除了用户提供的单词,其他全部使用人教版初中英语词汇表的单词,以及Sight words中220个单词,不要超出范围。"
  185. },
  186. {
  187. Key:"高中",
  188. Content:"难度上是中国的高三毕业的孩子可以阅读,生成的文章使用人教版高中英语词汇表的单词."
  189. },
  190. {
  191. Key:"大学",
  192. Content:"难度上是中国的大学毕业生的孩子可以阅读,生成的文章使用大学生六级词汇表的单词."
  193. },
  194. ]
  195. let content = "将"+words+"这些单词生成一篇英文文章。要求"+
  196. "[难度];"+
  197. "单词若是脏话,像'fuck'、'shit'等,可以忽略;"+
  198. "文章类型是'"+articleStyle+"([类型])';"+
  199. "要求提供丰富上下文线索、清晰的文章结构;"+
  200. "文章单词数在200个左右,最多不能超过300个;"+
  201. "文章按每句分成数组,且每句都有中文翻译;"+
  202. "提供5道针对文章阅读理解的单项选择题,各有四个选项,并提供答案;"+
  203. "单项选择题和选项也要有中文翻译;"+
  204. "如果文章生成中用到了用户提交的单词中,有单词的变形形式,比如用到单词的原型、复数形式、过去式、过去分词、比较级、最高级、第三人称单数等,请罗列出文章中单词所用到的形式;"+
  205. "内容格式为JSON,必须严格按照以下格式,不能有任何字段名错误或缺失:{"+
  206. " \"ArticleEnglish\":[\"<英文文章句子1>\",\"<英文文章句子2>\"...],"+
  207. " \"ArticleChinese\":[\"<中文翻译句子1>\",\"<中文翻译句子2>\"...],"+
  208. " \"FormsOfWords\":[\"<单词1形式1>\",\"<单词1形式2>\",\"<单词2形式1>\",...],"+
  209. " \"Question\":[{"+
  210. " \"QuestionEnglish\":\"<英语题目1>\","+
  211. " \"QuestionChinese\":\"<题目1中文翻译>\","+
  212. " \"OptionsEnglish\":[\"A.<英语选项1>\",\"B.<英语选项2>\",\"C.<英语选项3>\",\"D.<英语选项4>\"],"+
  213. " \"OptionsChinese\":[\"A.<选项1中文翻译>\",\"B.<选项2中文翻译>\",\"C.<选项3中文翻译>\",\"D.<选项4中文翻译>\"],"+
  214. " \"Answer\":\"<答案>\""+
  215. " }...]"+
  216. "}";
  217. if (!params.Level || params.Level=="" || params.Level==undefined || params.Level>="4"){
  218. params.Level="0";
  219. }
  220. content = content.replace("[难度]",LEVEL[Number(params.Level)].Content);
  221. if (articleStyle=="随机" || articleStyle=="任意") {
  222. // 获取ARTICLE_STYLE对象的所有键(文章类型)
  223. const articleTypes = Object.keys(ARTICLE_STYLE);
  224. // 随机选择一个文章类型
  225. const randomIndex = stringUtils.Random(0, articleTypes.length-1);
  226. articleStyle = articleTypes[randomIndex];
  227. }
  228. content = content.replace("[类型]",ARTICLE_STYLE[articleStyle]);
  229. //console.log("content:"+content);
  230. // 从请求参数中获取AI提供者,如果没有指定则使用默认值
  231. let aiProvider = DEFAULT_AI_PROVIDER;
  232. if (params.Level>=2){
  233. aiProvider="volces1-6";
  234. }
  235. try {
  236. // 使用aiController生成文章
  237. let result2 = await aiController.generateArticle(content, aiProvider);
  238. // 校验和修复JSON结构
  239. result2 = aiController.validateAndFixJSON(result2);
  240. console.log("JSON结构已校验和修复");
  241. let param2={};
  242. param2.UserID=ctx.query.UserID;
  243. param2.CreateTime=moment().format('YYYY-MM-DD HH:mm:ss');
  244. param2.Words=words;
  245. param2.Level=params.Level;
  246. param2.articleStyle=articleStyle;
  247. param2.BuildStr=content;
  248. try {
  249. // 尝试解析JSON以获取ArticleStart
  250. const jsonObj = JSON.parse(result2);
  251. param2.ArticleStart = jsonObj.ArticleEnglish && jsonObj.ArticleEnglish.length > 0 ?
  252. jsonObj.ArticleEnglish[0] : "No content available";
  253. } catch (error) {
  254. param2.ArticleStart = "Error parsing JSON";
  255. }
  256. // 去除JSON字符串中的所有换行符(\r\n, \n, \r)
  257. param2.JSONString=JSON.stringify(result2.replace(/[\r\n]+/g, ''));
  258. param2.Flag=0;
  259. await yjbdc.AddArticleInfo(param2);
  260. result = { errcode: 10000, result: result2 };
  261. globalCache.set(url, result, config.BufferMemoryTime);
  262. console.log("缓存60秒");
  263. }
  264. catch(err){
  265. result = { errcode: 10000, result: "-1" };
  266. }
  267. }
  268. ctx.body = result;
  269. }
  270. export async function GetYJBDCArticleList(ctx) {
  271. const param = {
  272. UserID: ctx.query.UserID || 0,
  273. ID:ctx.query.ID || 0,
  274. IsTodayCount: ctx.query.IsTodayCount || false,
  275. };
  276. // 尝试从缓存获取
  277. const url='GetYJBDCArticleList?UserID='+param.UserID+"&ID="+param.ID;
  278. let result = globalCache.get(url);
  279. if (result === 0) {
  280. result = await yjbdc.GetYJBDCArticleList(param);
  281. if (param.ID==0){
  282. for(let i=0;i<result.length;i++){
  283. let item=result[i];
  284. item.CreateTime=moment(item.CreateTime).format("YYYY年MM月DD日 HH:mm");
  285. //debugger;
  286. switch (Number(item.Level)){
  287. case 0:
  288. item.LevelStr="小学";
  289. break;
  290. case 1:
  291. item.LevelStr="初中";
  292. break;
  293. case 2:
  294. item.LevelStr="高中";
  295. break;
  296. case 3:
  297. item.LevelStr="大学";
  298. break;
  299. }
  300. }
  301. }
  302. globalCache.set(url, result, config.BufferMemoryTime);
  303. console.log("缓存60秒");
  304. }
  305. if (param.IsTodayCount && !param.ID){
  306. let count=0;
  307. const today=moment().format("YYYY年MM月DD日 00:00");
  308. for(let i=0;i<result.length;i++){
  309. if (result[i].CreateTime>=today)
  310. count++;
  311. }
  312. let maxcount=ONE_DAY_MAX_BUILD_COUNT;
  313. if (param.UserID<4){
  314. maxcount=100;
  315. }
  316. result={
  317. TodayCount:count,
  318. MaxCount:maxcount,
  319. };
  320. }
  321. ctx.body = {"errcode": 10000, result:result};
  322. }
  323. export async function DeleteYJBDCArticleList(ctx) {
  324. const param = {
  325. UserID: ctx.query.UserID || 0,
  326. ID: ctx.query.ID || 0,
  327. };
  328. if (param.ID>0){
  329. const url='GetYJBDCArticleList?UserID='+param.UserID+"&ID=0";
  330. globalCache.delete(url);
  331. await yjbdc.UpdateYJBDCArticle(param);
  332. }
  333. ctx.body = {"errcode": 10000};
  334. }
  335. export async function GeneratePDF(ctx) {
  336. const params = ctx.request.body;
  337. if (!params || !params.Content) {
  338. ctx.status = 400;
  339. ctx.body = { error: 'Invalid request body: Content is required' };
  340. return;
  341. }
  342. const content = params.Content;
  343. // 文章类型映射
  344. const ARTICLE_STYLE = {
  345. "成长": "Personal Growth",
  346. "童话": "Fairy Tales",
  347. "家庭亲子": "Family Stories",
  348. "人生励志": "Inspirational",
  349. "科幻": "Science Fiction",
  350. "奇幻": "Fantasy",
  351. "校园生活": "School Life",
  352. "节日文化": "Cultural Stories",
  353. "旅行": "Travel Stories",
  354. "科普": "Popular Science",
  355. "动物": "Animal Stories",
  356. "环保": "Environmental Stories",
  357. };
  358. // 等级难度
  359. const LEVEL = [
  360. "Primary school vocabulary size",
  361. "Junior high school vocabulary size",
  362. "High school vocabulary size",
  363. "College vocabulary size"
  364. ];
  365. try {
  366. // 创建新的 PDF 文档 - 使用A4尺寸
  367. const doc = new PDFDocument({
  368. size: 'A4', // 使用标准A4尺寸 (595.28 x 841.89 points)
  369. margins: {
  370. top: 0,
  371. bottom: 0,
  372. left: 0,
  373. right: 0
  374. },
  375. autoFirstPage: true
  376. });
  377. // 注册中文字体
  378. doc.registerFont('ChineseFont', './public/fonts/方正黑体简体.TTF');
  379. // 定义字体选择函数
  380. const selectFont = (text, defaultFont = 'Helvetica') => {
  381. if (/[\u4E00-\u9FFF]/.test(text)) {
  382. return 'ChineseFont';
  383. }
  384. return defaultFont;
  385. };
  386. // 收集生成的 PDF 数据
  387. const chunks = [];
  388. doc.on('data', (chunk) => chunks.push(chunk));
  389. // 像素到点(pt)的转换函数
  390. const pixelToPt = (pixel) => pixel * (595.28 / 2100);
  391. // 获取文章内容
  392. let articleText = "";
  393. if (content.ArticleEnglish) {
  394. articleText = Array.isArray(content.ArticleEnglish) ?
  395. content.ArticleEnglish.join(" ") : content.ArticleEnglish;
  396. } else {
  397. articleText = "No content available";
  398. }
  399. articleText = articleText.replace(/<[^>]*>/g, '');
  400. // 获取单词列表
  401. let words = [];
  402. if (content.Words) {
  403. words = typeof content.Words === 'string' ?
  404. content.Words.split(",") :
  405. (Array.isArray(content.Words) ? content.Words : []);
  406. }
  407. // 获取问题列表
  408. let questions = [];
  409. if (content.Question && Array.isArray(content.Question)) {
  410. questions = content.Question;
  411. }
  412. // doc.image('./public/images/PDF.png', 0, 0, {
  413. // width: pixelToPt(2100),
  414. // height: pixelToPt(2970)
  415. // });
  416. // 1. 标题 - 文章类型
  417. doc.font(selectFont(ARTICLE_STYLE[content.ArticleStyle] || "Story"))
  418. .fontSize(pixelToPt(48))
  419. .text(ARTICLE_STYLE[content.ArticleStyle] || "Story",
  420. pixelToPt(120), pixelToPt(110));
  421. // 2. 副标题 - 难度级别
  422. doc.font('Helvetica')
  423. .fontSize(pixelToPt(30))
  424. .text(LEVEL[content.Level] || "",
  425. pixelToPt(120), pixelToPt(170));
  426. // 3. 时间
  427. const currentDate = moment().format("YYYY年MM月DD日 HH:mm");
  428. if (params.CreateTime)
  429. currentDate=moment(params.CreateTime).format("YYYY年MM月DD日 HH:mm");
  430. doc.font('ChineseFont')
  431. .fontSize(pixelToPt(30))
  432. .text(currentDate,
  433. pixelToPt(120), pixelToPt(212));
  434. // 4. 黑线
  435. doc.rect(pixelToPt(120), pixelToPt(289), pixelToPt(537), pixelToPt(10))
  436. .fill('black');
  437. // 5. 单词列表
  438. doc.font('Helvetica')
  439. .fontSize(pixelToPt(36));
  440. let wordY = pixelToPt(364);
  441. words.slice(0, 10).forEach(word => {
  442. doc.text(word, pixelToPt(122), wordY, {
  443. width: pixelToPt(535),
  444. align: 'left'
  445. });
  446. wordY += pixelToPt(70);
  447. });
  448. // 6. 文章内容
  449. // 先计算文章内容的行数
  450. doc.font('Helvetica');
  451. // 使用48字号计算文本高度
  452. const fontSize48 = pixelToPt(48);
  453. doc.fontSize(fontSize48);
  454. const textHeight48 = doc.heightOfString(articleText, {
  455. width: pixelToPt(1240),
  456. lineGap: pixelToPt(40.5)
  457. });
  458. // 计算行数 (文本高度 / (字体大小 + 行间距))
  459. const lineHeight = fontSize48 + pixelToPt(40.5);
  460. const lineCount = Math.ceil(textHeight48 / lineHeight);
  461. // 如果行数超过18行,则使用42字号
  462. const fontSize = lineCount > 18 ? pixelToPt(42) : pixelToPt(48);
  463. // 渲染文章内容
  464. doc.fontSize(fontSize)
  465. .text(articleText, pixelToPt(740), pixelToPt(105), {
  466. width: pixelToPt(1240),
  467. lineGap: pixelToPt(40.5)
  468. });
  469. // 7. 黑线
  470. const articleEndY = doc.y;
  471. doc.rect(pixelToPt(120), articleEndY + pixelToPt(41),
  472. pixelToPt(1860), pixelToPt(10))
  473. .fill('black');
  474. // 8-13. 问题和答案
  475. if (questions.length > 0) {
  476. // 定义问题的X坐标位置
  477. const questionXPositions = [
  478. pixelToPt(120), // 问题1
  479. pixelToPt(120), // 问题2
  480. pixelToPt(740), // 问题3
  481. pixelToPt(740), // 问题4
  482. pixelToPt(1360) // 问题5
  483. ];
  484. // 存储每列最后一个选项的Y坐标位置
  485. let lastOptionYPositions = {
  486. column1: 0, // 用于跟踪第一列(问题1)的最后位置
  487. column2: 0, // 用于跟踪第二列(问题3)的最后位置
  488. };
  489. // 首先渲染问题1、3、5(第一行问题)
  490. const firstRowQuestions = [0, 2, 4]; // 问题1、3、5的索引
  491. for (const i of firstRowQuestions) {
  492. if (i < questions.length && questions[i]) {
  493. const currentX = questionXPositions[i];
  494. const currentY = articleEndY + pixelToPt(130); // 所有第一行问题的起始Y坐标相同
  495. // 渲染问题文本
  496. doc.font('Helvetica-Bold')
  497. .fontSize(pixelToPt(36))
  498. .text(`${i+1}. ${questions[i].QuestionEnglish || `Question ${i+1}`}`,
  499. currentX, currentY, {
  500. width: pixelToPt(540),
  501. align: 'left'
  502. });
  503. // 获取问题文本渲染后的Y坐标位置
  504. const questionEndY = doc.y;
  505. // 选项起始位置 = 问题结束位置 + 20像素间距
  506. let optionY = questionEndY + pixelToPt(28);
  507. // 渲染选项
  508. const options = questions[i].OptionsEnglish || [];
  509. const optionLabels = ['A', 'B', 'C', 'D'];
  510. // 设置选项标签宽度和选项总宽度
  511. const labelWidth = pixelToPt(40); // 标签(A./B./C./D.)的宽度
  512. const totalWidth = pixelToPt(496); // 选项的总宽度
  513. const contentWidth = totalWidth - labelWidth; // 选项内容的宽度
  514. options.forEach((opt, index) => {
  515. if (index < optionLabels.length) {
  516. // 提取选项内容,移除可能存在的标签前缀
  517. let optionContent = opt.replace(/^[A-D]\.\s*/, '');
  518. // 渲染选项标签(A./B./C./D.)
  519. doc.font('Helvetica')
  520. .fontSize(pixelToPt(36))
  521. .text(`${optionLabels[index]}.`, currentX, optionY, {
  522. width: labelWidth,
  523. align: 'left'
  524. });
  525. // 渲染选项内容,确保折行时与内容第一行对齐
  526. doc.font('Helvetica')
  527. .fontSize(pixelToPt(36))
  528. .text(optionContent, currentX + labelWidth, optionY, {
  529. width: contentWidth,
  530. align: 'left'
  531. });
  532. // 更新选项Y坐标,为下一个选项做准备
  533. // 获取当前位置,确保下一个选项在当前选项完全渲染后的位置
  534. optionY = doc.y + pixelToPt(8);
  535. }
  536. });
  537. // 保存该列最后一个选项的Y坐标
  538. if (i === 0) {
  539. lastOptionYPositions.column1 = doc.y;
  540. } else if (i === 2) {
  541. lastOptionYPositions.column2 = doc.y;
  542. }
  543. }
  544. }
  545. // 然后渲染问题2和4(第二行问题)
  546. if (questions.length > 1 && questions[1]) {
  547. // 问题2位于问题1的选项下方60像素处
  548. const question2Y = lastOptionYPositions.column1 + pixelToPt(75);
  549. doc.font('Helvetica-Bold')
  550. .fontSize(pixelToPt(36))
  551. .text(`2. ${questions[1].QuestionEnglish || "Question 2"}`,
  552. questionXPositions[1], question2Y, {
  553. width: pixelToPt(540),
  554. align: 'left'
  555. });
  556. // 获取问题文本渲染后的Y坐标位置
  557. const questionEndY = doc.y;
  558. // 选项起始位置 = 问题结束位置 + 20像素间距
  559. let optionY = questionEndY + pixelToPt(28);
  560. // 渲染选项
  561. const options = questions[1].OptionsEnglish || [];
  562. const optionLabels = ['A', 'B', 'C', 'D'];
  563. // 设置选项标签宽度和选项总宽度
  564. const labelWidth = pixelToPt(40); // 标签(A./B./C./D.)的宽度
  565. const totalWidth = pixelToPt(496); // 选项的总宽度
  566. const contentWidth = totalWidth - labelWidth; // 选项内容的宽度
  567. options.forEach((opt, index) => {
  568. if (index < optionLabels.length) {
  569. // 提取选项内容,移除可能存在的标签前缀
  570. let optionContent = opt.replace(/^[A-D]\.\s*/, '');
  571. // 渲染选项标签(A./B./C./D.)
  572. doc.font('Helvetica')
  573. .fontSize(pixelToPt(36))
  574. .text(`${optionLabels[index]}.`, questionXPositions[1], optionY, {
  575. width: labelWidth,
  576. align: 'left'
  577. });
  578. // 渲染选项内容,确保折行时与内容第一行对齐
  579. doc.font('Helvetica')
  580. .fontSize(pixelToPt(36))
  581. .text(optionContent, questionXPositions[1] + labelWidth, optionY, {
  582. width: contentWidth,
  583. align: 'left'
  584. });
  585. // 更新选项Y坐标,为下一个选项做准备
  586. optionY = doc.y + pixelToPt(8);
  587. }
  588. });
  589. }
  590. if (questions.length > 3 && questions[3]) {
  591. // 问题4位于问题3的选项下方60像素处
  592. const question4Y = lastOptionYPositions.column2 + pixelToPt(75);
  593. doc.font('Helvetica-Bold')
  594. .fontSize(pixelToPt(36))
  595. .text(`4. ${questions[3].QuestionEnglish || "Question 4"}`,
  596. questionXPositions[3], question4Y, {
  597. width: pixelToPt(540),
  598. align: 'left'
  599. });
  600. // 获取问题文本渲染后的Y坐标位置
  601. const questionEndY = doc.y;
  602. // 选项起始位置 = 问题结束位置 + 20像素间距
  603. let optionY = questionEndY + pixelToPt(28);
  604. // 渲染选项
  605. const options = questions[3].OptionsEnglish || [];
  606. const optionLabels = ['A', 'B', 'C', 'D'];
  607. // 设置选项标签宽度和选项总宽度
  608. const labelWidth = pixelToPt(40); // 标签(A./B./C./D.)的宽度
  609. const totalWidth = pixelToPt(496); // 选项的总宽度
  610. const contentWidth = totalWidth - labelWidth; // 选项内容的宽度
  611. options.forEach((opt, index) => {
  612. if (index < optionLabels.length) {
  613. // 提取选项内容,移除可能存在的标签前缀
  614. let optionContent = opt.replace(/^[A-D]\.\s*/, '');
  615. // 渲染选项标签(A./B./C./D.)
  616. doc.font('Helvetica')
  617. .fontSize(pixelToPt(36))
  618. .text(`${optionLabels[index]}.`, questionXPositions[3], optionY, {
  619. width: labelWidth,
  620. align: 'left'
  621. });
  622. // 渲染选项内容,确保折行时与内容第一行对齐
  623. doc.font('Helvetica')
  624. .fontSize(pixelToPt(36))
  625. .text(optionContent, questionXPositions[3] + labelWidth, optionY, {
  626. width: contentWidth,
  627. align: 'left'
  628. });
  629. // 更新选项Y坐标,为下一个选项做准备
  630. optionY = doc.y + pixelToPt(8);
  631. }
  632. });
  633. }
  634. // 问题5保持原位置不变
  635. if (questions.length > 4 && questions[4]) {
  636. const currentX = questionXPositions[4];
  637. const currentY = articleEndY + pixelToPt(130);
  638. doc.font('Helvetica-Bold')
  639. .fontSize(pixelToPt(36))
  640. .text(`5. ${questions[4].QuestionEnglish || "Question 5"}`,
  641. currentX, currentY, {
  642. width: pixelToPt(540),
  643. align: 'left'
  644. });
  645. const questionEndY = doc.y;
  646. let optionY = questionEndY + pixelToPt(28);
  647. // 渲染选项
  648. const options = questions[4].OptionsEnglish || [];
  649. const optionLabels = ['A', 'B', 'C', 'D'];
  650. // 设置选项标签宽度和选项总宽度
  651. const labelWidth = pixelToPt(40); // 标签(A./B./C./D.)的宽度
  652. const totalWidth = pixelToPt(496); // 选项的总宽度
  653. const contentWidth = totalWidth - labelWidth; // 选项内容的宽度
  654. options.forEach((opt, index) => {
  655. if (index < optionLabels.length) {
  656. // 提取选项内容,移除可能存在的标签前缀
  657. let optionContent = opt.replace(/^[A-D]\.\s*/, '');
  658. // 渲染选项标签(A./B./C./D.)
  659. doc.font('Helvetica')
  660. .fontSize(pixelToPt(36))
  661. .text(`${optionLabels[index]}.`, currentX, optionY, {
  662. width: labelWidth,
  663. align: 'left'
  664. });
  665. // 渲染选项内容,确保折行时与内容第一行对齐
  666. doc.font('Helvetica')
  667. .fontSize(pixelToPt(36))
  668. .text(optionContent, currentX + labelWidth, optionY, {
  669. width: contentWidth,
  670. align: 'left'
  671. });
  672. // 更新选项Y坐标,为下一个选项做准备
  673. optionY = doc.y + pixelToPt(8);
  674. }
  675. });
  676. }
  677. }
  678. // 14. 虚线
  679. const lastContentY = doc.page.height - pixelToPt(190);
  680. // doc.rect(pixelToPt(120), lastContentY,
  681. // pixelToPt(1630), pixelToPt(10))
  682. // .fill('#D2D2D2');
  683. doc.strokeColor('#D2D2D2')
  684. .dash(pixelToPt(20), { space: pixelToPt(28) }) // 设置虚线样式:宽20像素,间隔20像素
  685. .lineWidth(pixelToPt(10)) // 线高10像素
  686. .moveTo(pixelToPt(120), lastContentY)
  687. .lineTo(pixelToPt(120) + pixelToPt(1630), lastContentY)
  688. .stroke()
  689. .undash(); // 重置虚线样式
  690. // 15-19. 答案和底部信息
  691. doc.font('Helvetica-Bold') // 使用Helvetica-Bold作为Semibold替代
  692. .fontSize(pixelToPt(36))
  693. .fillColor('black'); // 重置颜色为黑色
  694. const answersY = doc.page.height - pixelToPt(144);
  695. // 获取问题答案
  696. const answers = [];
  697. if (questions.length > 0) {
  698. for (let i = 0; i < Math.min(questions.length, 5); i++) {
  699. const answer = questions[i].Answer || "";
  700. answers.push(`${i+1}. ${answer}`);
  701. }
  702. }
  703. // 显示答案(如果存在)
  704. const answerPositions = [120, 262, 411, 561, 711];
  705. for (let i = 0; i < Math.min(answers.length, 5); i++) {
  706. doc.text(answers[i], pixelToPt(answerPositions[i]), answersY+2, {
  707. width: pixelToPt(100),
  708. align: 'left'
  709. });
  710. }
  711. // 20-21. 应用名称
  712. doc.font('ChineseFont')
  713. .fontSize(pixelToPt(36))
  714. .text("语境背单词(微信小程序)", pixelToPt(1338), answersY,{
  715. width: pixelToPt(440),
  716. align: 'right'
  717. });
  718. // 添加二维码图片
  719. try {
  720. // 计算图片位置:距离右边120像素,距离底部100像素
  721. // 由于PDFKit使用左上角坐标系,需要计算左上角坐标
  722. const qrCodeWidth = pixelToPt(200);
  723. const qrCodeHeight = pixelToPt(200);
  724. const qrCodeX = doc.page.width - pixelToPt(120) - qrCodeWidth; // 右边距离转换为左边距离
  725. const qrCodeY = doc.page.height - pixelToPt(100) - qrCodeHeight; // 底部距离转换为顶部距离
  726. // 添加二维码图片
  727. doc.image('./public/images/acode/YJBDC_QRCode.png', qrCodeX, qrCodeY, {
  728. width: qrCodeWidth,
  729. height: qrCodeHeight
  730. });
  731. console.log("QR Code added successfully");
  732. } catch (imgError) {
  733. console.error("Error adding QR Code image:", imgError);
  734. }
  735. // 结束PDF生成
  736. doc.end();
  737. // 等待PDF生成完成
  738. const pdfBuffer = await new Promise((resolve) => {
  739. doc.on('end', () => {
  740. resolve(Buffer.concat(chunks));
  741. });
  742. });
  743. // 设置响应头
  744. ctx.set('Content-Type', 'application/pdf');
  745. ctx.set('Content-Disposition', 'attachment; filename=article.pdf');
  746. ctx.body = pdfBuffer;
  747. } catch (error) {
  748. console.error("Error generating PDF:", error);
  749. ctx.status = 500;
  750. ctx.body = { error: "Failed to generate PDF" };
  751. }
  752. }
  753. export async function BuildYJBDCQRCode(ctx) {
  754. try {
  755. // 获取微信访问令牌
  756. const tokenUrl = `https://api.weixin.qq.com/cgi-bin/token?appid=${config.wx.yjbdc_appid}&secret=${config.wx.yjbdc_appsecret}&grant_type=client_credential`;
  757. const tokenResponse = await axios.get(tokenUrl);
  758. const tokenData = tokenResponse.data;
  759. if (!tokenData || !tokenData.access_token) {
  760. ctx.status = 400;
  761. ctx.body = { errcode: 101, errStr: '获取微信访问令牌失败' };
  762. return;
  763. }
  764. const accessToken = tokenData.access_token;
  765. // 生成小程序码
  766. const qrCodeUrl = `https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=${accessToken}`;
  767. const path = './public/images/acode/';
  768. const imageUrl = 'YJBDC_QRCode.png';
  769. const fullPath = path + imageUrl;
  770. // 确保目录存在
  771. if (!fs.existsSync(path)) {
  772. fs.mkdirSync(path, { recursive: true });
  773. }
  774. const postData = {
  775. width: 280,
  776. scene: "SourceID=187"
  777. };
  778. // 使用axios获取二维码并保存到文件
  779. const qrCodeResponse = await axios({
  780. method: 'POST',
  781. url: qrCodeUrl,
  782. data: postData,
  783. responseType: 'stream'
  784. });
  785. // 创建写入流
  786. const writer = fs.createWriteStream(fullPath);
  787. // 将响应数据写入文件
  788. qrCodeResponse.data.pipe(writer);
  789. // 返回成功响应
  790. ctx.body = { errcode: 10000, message: "二维码生成请求已发送" };
  791. // 处理文件写入完成事件
  792. writer.on('finish', () => {
  793. console.log("二维码生成成功:", fullPath);
  794. });
  795. // 处理错误
  796. writer.on('error', (err) => {
  797. console.error("二维码文件写入失败:", err);
  798. });
  799. } catch (error) {
  800. console.error("生成二维码失败:", error);
  801. ctx.status = 500;
  802. ctx.body = {
  803. errcode: 500,
  804. errStr: '生成二维码失败',
  805. error: error.message
  806. };
  807. }
  808. }