444 lines
20 KiB
C#
444 lines
20 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Data;
|
||
using System.Data.SqlClient;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Text.RegularExpressions;
|
||
|
||
namespace BLL
|
||
{
|
||
/// <summary>
|
||
/// PDF 书籍模块 WebAPI 数据服务(移动端 / 第三方系统集成用)。
|
||
/// 对应 PC 页面 HSSE/PDFDOC/PdfBookView.aspx 的完整功能:书籍、多级章节目录、章节正文(富文本 HTML)的
|
||
/// 查询与维护(新增书籍 / 新增·编辑·删除章节 / 保存章节正文),并额外提供章节关键词检索。
|
||
/// 数据表:Sys_PdfBook / Sys_PdfChapter(正文存 [Text] 列,为 UMeditor 生成的 HTML)。
|
||
/// 说明:目录结构用 ParentChapterId + Level(1章 2节 3小节) 表达多级层级,SortNo 控制同级顺序。
|
||
/// </summary>
|
||
public static class APIPdfBookService
|
||
{
|
||
#region 返回模型
|
||
/// <summary>书籍列表项。</summary>
|
||
public class BookItem
|
||
{
|
||
/// <summary>书籍主键。</summary>
|
||
public string BookId { get; set; }
|
||
/// <summary>书名。</summary>
|
||
public string Title { get; set; }
|
||
/// <summary>章节总数。</summary>
|
||
public int ChapterCount { get; set; }
|
||
/// <summary>登记时间。</summary>
|
||
public DateTime? CreateTime { get; set; }
|
||
}
|
||
|
||
/// <summary>章节目录树节点(多级嵌套)。</summary>
|
||
public class ChapterTreeNode
|
||
{
|
||
/// <summary>章节主键。</summary>
|
||
public string ChapterId { get; set; }
|
||
/// <summary>章节名称。</summary>
|
||
public string ChapterTitle { get; set; }
|
||
/// <summary>层级:1章 2节 3小节。</summary>
|
||
public int Level { get; set; }
|
||
/// <summary>起始页码(无则为0)。</summary>
|
||
public int PageStart { get; set; }
|
||
/// <summary>结束页码(无则为0)。</summary>
|
||
public int PageEnd { get; set; }
|
||
/// <summary>是否已有正文内容(用于客户端标识可读章节)。</summary>
|
||
public bool HasContent { get; set; }
|
||
/// <summary>子章节列表(叶子章节为空数组)。</summary>
|
||
public List<ChapterTreeNode> Children { get; set; }
|
||
}
|
||
|
||
/// <summary>章节正文详情。</summary>
|
||
public class ChapterDetailItem
|
||
{
|
||
/// <summary>章节主键。</summary>
|
||
public string ChapterId { get; set; }
|
||
/// <summary>所属书籍主键。</summary>
|
||
public string BookId { get; set; }
|
||
/// <summary>所属书籍名称。</summary>
|
||
public string BookTitle { get; set; }
|
||
/// <summary>章节名称。</summary>
|
||
public string ChapterTitle { get; set; }
|
||
/// <summary>父章节主键(顶级为 null)。</summary>
|
||
public string ParentChapterId { get; set; }
|
||
/// <summary>层级:1章 2节 3小节。</summary>
|
||
public int Level { get; set; }
|
||
/// <summary>起始页码(无则为0)。</summary>
|
||
public int PageStart { get; set; }
|
||
/// <summary>结束页码(无则为0)。</summary>
|
||
public int PageEnd { get; set; }
|
||
/// <summary>正文(富文本 HTML,可直接用 WebView 渲染)。</summary>
|
||
public string Content { get; set; }
|
||
/// <summary>正文纯文本(去掉 HTML 标签,便于全文展示/搜索)。</summary>
|
||
public string ContentText { get; set; }
|
||
}
|
||
|
||
/// <summary>章节关键词检索命中项。</summary>
|
||
public class SearchHitItem
|
||
{
|
||
/// <summary>书籍主键。</summary>
|
||
public string BookId { get; set; }
|
||
/// <summary>书籍名称。</summary>
|
||
public string BookTitle { get; set; }
|
||
/// <summary>章节主键。</summary>
|
||
public string ChapterId { get; set; }
|
||
/// <summary>章节名称。</summary>
|
||
public string ChapterTitle { get; set; }
|
||
/// <summary>起始页码(无则为0)。</summary>
|
||
public int PageStart { get; set; }
|
||
/// <summary>结束页码(无则为0)。</summary>
|
||
public int PageEnd { get; set; }
|
||
/// <summary>命中片段(关键词前后各约40字)。</summary>
|
||
public string Snippet { get; set; }
|
||
}
|
||
#endregion
|
||
|
||
#region 查询:书籍列表
|
||
/// <summary>
|
||
/// 获取全部书籍(按登记时间升序,与页面下拉一致)。
|
||
/// </summary>
|
||
public static List<BookItem> GetBooks()
|
||
{
|
||
var list = new List<BookItem>();
|
||
DataTable dt = SQLHelper.GetDataTableRunText(
|
||
"SELECT b.BookId, b.Title, b.CreateTime, " +
|
||
"(SELECT COUNT(*) FROM dbo.Sys_PdfChapter c WHERE c.BookId = b.BookId) AS ChapterCount " +
|
||
"FROM dbo.Sys_PdfBook b ORDER BY b.CreateTime", new SqlParameter[] { });
|
||
if (dt == null)
|
||
return list;
|
||
foreach (DataRow r in dt.Rows)
|
||
{
|
||
list.Add(new BookItem
|
||
{
|
||
BookId = r["BookId"] == null ? "" : r["BookId"].ToString(),
|
||
Title = r["Title"] == null ? "" : r["Title"].ToString(),
|
||
ChapterCount = r["ChapterCount"] == DBNull.Value ? 0 : Convert.ToInt32(r["ChapterCount"]),
|
||
CreateTime = r["CreateTime"] == DBNull.Value ? (DateTime?)null : Convert.ToDateTime(r["CreateTime"])
|
||
});
|
||
}
|
||
return list;
|
||
}
|
||
#endregion
|
||
|
||
#region 查询:多级章节目录树
|
||
/// <summary>
|
||
/// 获取某本书的多级章节目录树(嵌套 Children,顶级为根的数组;按 SortNo 排序)。
|
||
/// </summary>
|
||
public static List<ChapterTreeNode> GetChapterTree(string bookId)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(bookId))
|
||
throw new Exception("bookId 不能为空");
|
||
|
||
var chapters = PdfBookService.GetChapters(bookId);
|
||
var result = new List<ChapterTreeNode>();
|
||
if (chapters == null || chapters.Count == 0)
|
||
return result;
|
||
|
||
// 有正文标记(一次查询)
|
||
var hasContentSet = new HashSet<string>();
|
||
DataTable dt = SQLHelper.GetDataTableRunText(
|
||
"SELECT ChapterId FROM dbo.Sys_PdfChapter " +
|
||
"WHERE BookId = @BookId AND [Text] IS NOT NULL AND LTRIM(RTRIM([Text])) <> N''",
|
||
new[] { new SqlParameter("@BookId", bookId) });
|
||
if (dt != null)
|
||
foreach (DataRow r in dt.Rows)
|
||
hasContentSet.Add(r["ChapterId"] == null ? "" : r["ChapterId"].ToString());
|
||
|
||
// 1) 先为每个章节建节点
|
||
var nodeMap = new Dictionary<string, ChapterTreeNode>();
|
||
foreach (PdfChapter c in chapters)
|
||
{
|
||
nodeMap[c.ChapterId] = new ChapterTreeNode
|
||
{
|
||
ChapterId = c.ChapterId,
|
||
ChapterTitle = c.Title,
|
||
Level = c.Level,
|
||
PageStart = c.PageStart,
|
||
PageEnd = c.PageEnd,
|
||
HasContent = hasContentSet.Contains(c.ChapterId),
|
||
Children = new List<ChapterTreeNode>()
|
||
};
|
||
}
|
||
|
||
// 2) 按父章节挂接(父不在列表中的作为顶级)
|
||
foreach (PdfChapter c in chapters)
|
||
{
|
||
var node = nodeMap[c.ChapterId];
|
||
if (!string.IsNullOrWhiteSpace(c.ParentChapterId) && nodeMap.ContainsKey(c.ParentChapterId))
|
||
nodeMap[c.ParentChapterId].Children.Add(node);
|
||
else
|
||
result.Add(node);
|
||
}
|
||
return result;
|
||
}
|
||
#endregion
|
||
|
||
#region 查询:章节正文
|
||
/// <summary>
|
||
/// 获取章节正文详情(含 HTML 与纯文本);章节不存在返回 null。
|
||
/// </summary>
|
||
public static ChapterDetailItem GetChapterDetail(string chapterId)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(chapterId))
|
||
throw new Exception("chapterId 不能为空");
|
||
|
||
DataTable dt = SQLHelper.GetDataTableRunText(
|
||
"SELECT c.ChapterId, c.BookId, b.Title AS BookTitle, c.ParentChapterId, c.[Level], " +
|
||
"c.ChapterTitle, c.PageStart, c.PageEnd, c.[Text] " +
|
||
"FROM dbo.Sys_PdfChapter c INNER JOIN dbo.Sys_PdfBook b ON c.BookId = b.BookId " +
|
||
"WHERE c.ChapterId = @ChapterId",
|
||
new[] { new SqlParameter("@ChapterId", chapterId) });
|
||
if (dt == null || dt.Rows.Count == 0)
|
||
return null;
|
||
|
||
DataRow r = dt.Rows[0];
|
||
string html = r["Text"] == DBNull.Value ? "" : r["Text"].ToString();
|
||
var item = new ChapterDetailItem
|
||
{
|
||
ChapterId = r["ChapterId"] == null ? "" : r["ChapterId"].ToString(),
|
||
BookId = r["BookId"] == null ? "" : r["BookId"].ToString(),
|
||
BookTitle = r["BookTitle"] == null ? "" : r["BookTitle"].ToString(),
|
||
ParentChapterId = r["ParentChapterId"] == DBNull.Value ? null : r["ParentChapterId"].ToString(),
|
||
Level = r["Level"] == DBNull.Value ? 1 : Convert.ToInt32(r["Level"]),
|
||
ChapterTitle = r["ChapterTitle"] == null ? "" : r["ChapterTitle"].ToString(),
|
||
PageStart = r["PageStart"] == DBNull.Value ? 0 : Convert.ToInt32(r["PageStart"]),
|
||
PageEnd = r["PageEnd"] == DBNull.Value ? 0 : Convert.ToInt32(r["PageEnd"]),
|
||
Content = html,
|
||
ContentText = HtmlToPlainText(html)
|
||
};
|
||
return item;
|
||
}
|
||
#endregion
|
||
|
||
#region 查询:章节关键词检索
|
||
/// <summary>
|
||
/// 章节关键词检索:在章节正文中按关键词做字面匹配(正文为 HTML,匹配前先剔除标签)。
|
||
/// bookId 可空:不传则全库检索。
|
||
/// </summary>
|
||
public static List<SearchHitItem> SearchChapter(string keyword, string bookId)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(keyword))
|
||
throw new Exception("keyword 不能为空");
|
||
|
||
// LIKE 通配符转义(ESCAPE 字符为反斜杠)
|
||
string esc = keyword.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_");
|
||
var ps = new List<SqlParameter>
|
||
{
|
||
new SqlParameter("@Keyword", "%" + esc + "%")
|
||
};
|
||
string where = "c.[Text] LIKE @Keyword ESCAPE '\\'";
|
||
if (!string.IsNullOrWhiteSpace(bookId))
|
||
{
|
||
where += " AND c.BookId = @BookId";
|
||
ps.Add(new SqlParameter("@BookId", bookId));
|
||
}
|
||
|
||
DataTable dt = SQLHelper.GetDataTableRunText(
|
||
"SELECT c.ChapterId, c.ChapterTitle, c.BookId, b.Title AS BookTitle, " +
|
||
"c.PageStart, c.PageEnd, c.[Text] " +
|
||
"FROM dbo.Sys_PdfChapter c INNER JOIN dbo.Sys_PdfBook b ON c.BookId = b.BookId " +
|
||
"WHERE " + where + " ORDER BY b.Title, c.SortNo", ps.ToArray());
|
||
|
||
var list = new List<SearchHitItem>();
|
||
if (dt == null)
|
||
return list;
|
||
foreach (DataRow r in dt.Rows)
|
||
{
|
||
string html = r["Text"] == DBNull.Value ? "" : r["Text"].ToString();
|
||
string plain = HtmlToPlainText(html);
|
||
list.Add(new SearchHitItem
|
||
{
|
||
BookId = r["BookId"] == null ? "" : r["BookId"].ToString(),
|
||
BookTitle = r["BookTitle"] == null ? "" : r["BookTitle"].ToString(),
|
||
ChapterId = r["ChapterId"] == null ? "" : r["ChapterId"].ToString(),
|
||
ChapterTitle = r["ChapterTitle"] == null ? "" : r["ChapterTitle"].ToString(),
|
||
PageStart = r["PageStart"] == DBNull.Value ? 0 : Convert.ToInt32(r["PageStart"]),
|
||
PageEnd = r["PageEnd"] == DBNull.Value ? 0 : Convert.ToInt32(r["PageEnd"]),
|
||
Snippet = BuildSnippet(plain, keyword)
|
||
});
|
||
}
|
||
return list;
|
||
}
|
||
|
||
/// <summary>取关键词命中片段(前后各约40字)。</summary>
|
||
private static string BuildSnippet(string plain, string keyword)
|
||
{
|
||
if (string.IsNullOrEmpty(plain))
|
||
return "";
|
||
int idx = plain.IndexOf(keyword, StringComparison.OrdinalIgnoreCase);
|
||
if (idx < 0)
|
||
return plain.Length > 120 ? plain.Substring(0, 120) + "……" : plain;
|
||
int start = Math.Max(0, idx - 40);
|
||
int len = Math.Min(plain.Length - start, keyword.Length + 80);
|
||
return (start > 0 ? "……" : "") + plain.Substring(start, len) + (start + len < plain.Length ? "……" : "");
|
||
}
|
||
|
||
/// <summary>HTML 转纯文本(去标签、解码实体、压缩空白)。</summary>
|
||
private static string HtmlToPlainText(string html)
|
||
{
|
||
if (string.IsNullOrEmpty(html))
|
||
return "";
|
||
string s = Regex.Replace(html, "(?i)<(br|/p|/div|/li|/tr|/h[1-6])[^>]*>", "\n");
|
||
s = Regex.Replace(s, "<[^>]+>", "");
|
||
s = System.Net.WebUtility.HtmlDecode(s);
|
||
s = Regex.Replace(s, "[\r\n]+", "\n");
|
||
s = Regex.Replace(s, "[ \t]+", " ");
|
||
return s.Trim();
|
||
}
|
||
#endregion
|
||
|
||
#region 维护:书籍
|
||
/// <summary>
|
||
/// 新增书籍(仅书名;FilePath/TotalPages/IndexStatus 与页面一致:NULL/0/2)。
|
||
/// </summary>
|
||
public static string AddBook(string title, string createUser)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(title))
|
||
throw new Exception("书名不能为空");
|
||
if (string.IsNullOrWhiteSpace(createUser))
|
||
createUser = "api";
|
||
|
||
string bookId = NewId("PB");
|
||
SQLHelper.ExecutSql(
|
||
"INSERT INTO dbo.Sys_PdfBook (BookId, Title, FilePath, TotalPages, IndexStatus, CreateUser, CreateTime) VALUES ('"
|
||
+ SqlEsc(bookId) + "',N'" + SqlEsc(title.Trim()) + "',NULL,0,2,N'"
|
||
+ SqlEsc(createUser) + "',GETDATE())");
|
||
return bookId;
|
||
}
|
||
#endregion
|
||
|
||
#region 维护:章节(新增 / 改名 / 删除 / 保存正文)
|
||
/// <summary>
|
||
/// 新增章节:parentChapterId 为空则为顶级章节,否则挂到指定父章节下(层级=父级+1)。
|
||
/// SortNo 取全书当前最大值+1(与页面逻辑一致)。
|
||
/// </summary>
|
||
public static string AddChapter(string bookId, string parentChapterId, string chapterTitle, string createUser)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(bookId))
|
||
throw new Exception("bookId 不能为空");
|
||
if (string.IsNullOrWhiteSpace(chapterTitle))
|
||
throw new Exception("章节名称不能为空");
|
||
|
||
int level = 1;
|
||
if (!string.IsNullOrWhiteSpace(parentChapterId))
|
||
{
|
||
int parentLevel = GetInt(
|
||
"SELECT ISNULL([Level],1) FROM dbo.Sys_PdfChapter WHERE ChapterId='" + SqlEsc(parentChapterId) + "'");
|
||
if (parentLevel <= 0)
|
||
throw new Exception("父章节不存在:" + parentChapterId);
|
||
level = parentLevel + 1;
|
||
}
|
||
int sort = GetInt(
|
||
"SELECT ISNULL(MAX(SortNo),0) FROM dbo.Sys_PdfChapter WHERE BookId='" + SqlEsc(bookId) + "'") + 1;
|
||
|
||
string chapterId = NewId("CH");
|
||
SQLHelper.ExecutSql(
|
||
"INSERT INTO dbo.Sys_PdfChapter (ChapterId, BookId, ParentChapterId, [Level], ChapterTitle, PageStart, PageEnd, [Text], SortNo) VALUES ('"
|
||
+ SqlEsc(chapterId) + "','" + SqlEsc(bookId) + "',"
|
||
+ (string.IsNullOrWhiteSpace(parentChapterId) ? "NULL" : "'" + SqlEsc(parentChapterId) + "'")
|
||
+ "," + level + ",N'" + SqlEsc(chapterTitle.Trim()) + "',NULL,NULL,N''," + sort + ")");
|
||
return chapterId;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 编辑章节:仅修改章节名称。
|
||
/// </summary>
|
||
public static void EditChapter(string chapterId, string chapterTitle)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(chapterId))
|
||
throw new Exception("chapterId 不能为空");
|
||
if (string.IsNullOrWhiteSpace(chapterTitle))
|
||
throw new Exception("章节名称不能为空");
|
||
if (GetInt("SELECT COUNT(*) FROM dbo.Sys_PdfChapter WHERE ChapterId='" + SqlEsc(chapterId) + "'") == 0)
|
||
throw new Exception("章节不存在:" + chapterId);
|
||
|
||
SQLHelper.ExecutSql("UPDATE dbo.Sys_PdfChapter SET ChapterTitle=N'" + SqlEsc(chapterTitle.Trim())
|
||
+ "' WHERE ChapterId='" + SqlEsc(chapterId) + "'");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 删除章节及其全部子章节(递归收集自身+后代后一次性删除)。
|
||
/// </summary>
|
||
public static int DeleteChapter(string chapterId)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(chapterId))
|
||
throw new Exception("chapterId 不能为空");
|
||
|
||
DataTable dt = SQLHelper.GetDataTableRunText(
|
||
"SELECT BookId FROM dbo.Sys_PdfChapter WHERE ChapterId=@ChapterId",
|
||
new[] { new SqlParameter("@ChapterId", chapterId) });
|
||
if (dt == null || dt.Rows.Count == 0)
|
||
throw new Exception("章节不存在:" + chapterId);
|
||
string bookId = dt.Rows[0]["BookId"].ToString();
|
||
|
||
var all = PdfBookService.GetChapters(bookId);
|
||
var ids = new List<string> { chapterId };
|
||
bool changed = true;
|
||
while (changed)
|
||
{
|
||
changed = false;
|
||
foreach (PdfChapter c in all)
|
||
{
|
||
if (!ids.Contains(c.ChapterId) && ids.Contains(c.ParentChapterId ?? ""))
|
||
{
|
||
ids.Add(c.ChapterId);
|
||
changed = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
var sb = new StringBuilder();
|
||
foreach (string id in ids)
|
||
{
|
||
if (sb.Length > 0)
|
||
sb.Append(",");
|
||
sb.Append("'").Append(id.Replace("'", "''")).Append("'");
|
||
}
|
||
SQLHelper.ExecutSql("DELETE FROM dbo.Sys_PdfChapter WHERE ChapterId IN (" + sb + ")");
|
||
return ids.Count;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 保存章节正文(富文本 HTML,与页面"保存内容"一致)。
|
||
/// </summary>
|
||
public static void SaveContent(string chapterId, string content)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(chapterId))
|
||
throw new Exception("chapterId 不能为空");
|
||
if (GetInt("SELECT COUNT(*) FROM dbo.Sys_PdfChapter WHERE ChapterId='" + SqlEsc(chapterId) + "'") == 0)
|
||
throw new Exception("章节不存在:" + chapterId);
|
||
|
||
SQLHelper.ExecutSql("UPDATE dbo.Sys_PdfChapter SET [Text]=N'" + SqlEsc(content ?? "")
|
||
+ "' WHERE ChapterId='" + SqlEsc(chapterId) + "'");
|
||
}
|
||
#endregion
|
||
|
||
#region 工具
|
||
/// <summary>SQL 字符串转义。</summary>
|
||
private static string SqlEsc(string s)
|
||
{
|
||
return (s ?? "").Replace("'", "''");
|
||
}
|
||
|
||
/// <summary>执行 SELECT 首行首列并转 int(查无返回0)。</summary>
|
||
private static int GetInt(string sql)
|
||
{
|
||
DataTable dt = SQLHelper.GetDataTableRunText(sql, new SqlParameter[] { });
|
||
if (dt == null || dt.Rows.Count == 0)
|
||
return 0;
|
||
object v = dt.Rows[0][0];
|
||
return v == DBNull.Value || v == null ? 0 : Convert.ToInt32(v);
|
||
}
|
||
|
||
/// <summary>生成业务主键(前缀+时间+随机,与页面一致)。</summary>
|
||
private static string NewId(string prefix)
|
||
{
|
||
return prefix + DateTime.Now.ToString("yyyyMMddHHmmss")
|
||
+ Guid.NewGuid().ToString("N").Substring(0, 4);
|
||
}
|
||
#endregion
|
||
}
|
||
}
|