Java机器学习图书推荐系统源码:ItemCF与TF-IDF工程实践

发布时间:2026/11/5 11:12:17
Java机器学习图书推荐系统源码:ItemCF与TF-IDF工程实践 简介这份源码包围绕基于机器学习的图书推荐系统展开面向Java Web课程设计、毕业设计以及希望实践协同过滤推荐算法的开发者重点解决图书个性化推荐与后台管理问题。项目包含图书管理、读书会管理及科技、人文、历史、中小学等图书分类推荐模块推荐部分先提取图书特征再依据用户点击行为记录偏好计算喜好图书与候选图书间的距离并返回近邻结果前端由HTML、CSS与JSP页面呈现后端基于SpringMVC组织。压缩包共172个文件、约17MB含25个java源文件、58个class、32个jsp页面、12个jar依赖、8个xml配置及1个sql脚本等覆盖源码、页面、依赖与建库脚本目录按控制层、数据访问层、实体与页面资源组织。已有259人学习适合对照完整工程理解推荐流程、模块划分与数据库设计也可作为二次开发的基础并参考特征提取、行为记录、距离计算到推荐输出的实现链路。1. 一份 Java 写的机器学习图书推荐系统源码真正的门槛在哪第一次打开这类源码的人注意力一般都在算法部分是不是用了矩阵分解、有没有上深度学习。我拆这份机器学习图书推荐系统源码时先看的却是另外两处——评分表的时间字段和相似度矩阵的存储方式。原因很直接图书推荐这个业务的天然特征是“用户少、图书多、行为稀疏”绝大多数翻车都发生在数据口径和工程实现上而不是模型选得不够新。这套源码用 Java 承担在线服务Python 侧做离线训练和相似度计算中间靠 CSV 与模型文件交换适合中小体量的图书电商、馆藏检索终端、内容社区的书单模块。下面按“能不能跑通 → 怎么调 → 坑在哪”的顺序往下拆。2. 工程结构与数据口径先把“推荐什么”定义清楚很多人拿到源码第一反应是改模型我一般反过来先把数据链路和目录结构读一遍搞清楚线上那次请求到底用到了哪几份产物。图书推荐的效果上限八成在样本口径定下来那一刻就决定了。同样一份 ItemCF 代码评分阈值取 3 和取 4跑出来的推荐列表能差出一个量级这不是玄学是样本定义变了。2.1 源码目录的三层划分业务层、算法层、调度层这套工程大致分三层读代码时按层切比按文件切快得多。目录职责改动频率controller/service/mapper请求处理、鉴权、分页、缓存读写高业务需求都在这algoJava 侧加载相似度矩阵、多路召回、融合排序中调权重时动offlinePython 侧相似度计算、内容向量、离线指标低跑通后很少碰job定时导出样本、触发训练、回灌产物低resources/model相似度文件、TF-IDF 向量、ID 映射表由 job 覆盖分层的价值在回滚成本。模型产物和代码解耦之后某次重训效果变差只要把上一版相似度文件和 ID 映射一起换回去服务重启即可恢复不用回滚代码。我见过把相似度矩阵直接算在 Service 方法里的写法换一版权重就得发一次版线上的问题还没定位完发布流程先走完了。调度层的任务顺序也不能乱先导出样本再算相似度最后写 ID 映射。映射表必须和相似度文件同一批次产出否则线上拿新映射去查旧矩阵轻则推荐错乱重则数组越界。2.2 三张核心表的字段与采样口径图书推荐最小可用的数据模型就三张表图书、用户、评分或行为。字段设计不难难的是口径。-- 图书表内容召回的原料都在这里 CREATE TABLE book ( book_id BIGINT PRIMARY KEY, title VARCHAR(128) NOT NULL, author VARCHAR(64), category VARCHAR(32), -- 一级分类用于打散 tags VARCHAR(255), -- 逗号分隔内容召回的主力特征 intro TEXT, -- 简介注意截断别整段塞进向量 publish_year SMALLINT, heat INT DEFAULT 0 -- 近 30 天曝光点击加权热度兜底用 ); -- 行为表隐式反馈和显式评分可以并存用 type 区分 CREATE TABLE rating ( user_id BIGINT, book_id BIGINT, score TINYINT, -- 1~5浏览/收藏折算成 1~3 type TINYINT, -- 1 评分 2 收藏 3 浏览 ts BIGINT, -- 毫秒时间戳切训练测试集全靠它 PRIMARY KEY (user_id, book_id, type) );采样口径有四条硬规则建议直接写进导出 SQL 而不是事后在 Python 里补正样本阈值取 4 分以上。3 分是“还行”把它当正样本会把中庸图书的相似度整体抬高。时间窗取近 180 天。图书兴趣衰减比商品慢但超过半年的行为噪声明显变大。交互少于 5 条的用户直接剔除。这类用户算不出稳定邻居留在训练集里只会污染共现矩阵。同一用户对同一本书多次行为只保留最高分那条否则重复行为会把共现次数刷高。提示把阈值做成配置项而不是硬编码。换业务场景比如换成教材推荐时正样本阈值和窗口长度几乎一定要重调。2.3 从数据库到模型文件一次完整的训练样本导出Java 侧负责把样本导出成 UTF-8 的 CSVPython 侧只认文件不连数据库。这个约定看着土但排查问题时极其省事——出问题先把那份 CSV 拉下来看一眼样本对不对立刻清楚。/** * 导出训练样本只导出时间窗内、交互数达标的用户 * param from 起始时间一般取 now - 180d * param out 输出文件按批次命名 ratings_20240601.csv */ public void exportRatings(LocalDate from, Path out) throws Exception { String sql SELECT r.user_id, r.book_id, MAX(r.score) AS score, MAX(r.ts) AS ts FROM rating r JOIN (SELECT user_id FROM rating WHERE ts ? GROUP BY user_id HAVING COUNT(DISTINCT book_id) 5) u ON u.user_id r.user_id WHERE r.ts ? GROUP BY r.user_id, r.book_id ORDER BY r.ts ; long fromMs from.atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli(); try (Connection c dataSource.getConnection(); PreparedStatement ps c.prepareStatement(sql); BufferedWriter w Files.newBufferedWriter(out, StandardCharsets.UTF_8)) { ps.setLong(1, fromMs); ps.setLong(2, fromMs); ps.setFetchSize(5000); // 防止一次性把结果集拉进堆 try (ResultSet rs ps.executeQuery()) { w.write(user_id,book_id,score,ts\n); while (rs.next()) { w.write(rs.getLong(1) , rs.getLong(2) , rs.getInt(3) , rs.getLong(4) \n); } } } }逻辑上有两个点值得说。一是子查询先把“交互数 ≥ 5”的用户筛出来再 JOIN比全表扫完再在内存里过滤快一个数量级二是ps.setFetchSize(5000)MySQL 驱动默认会把整个结果集读进内存几十万行样本就够把导出任务撑爆这个参数不加导出跑一半 OOM 是常事。参数上from决定窗口长度HAVING COUNT 5决定用户门槛两个数都建议外置到配置文件。图书量在十万级、行为在千万级以内时这套“Java 导出 Python 单机算”完全够用上分布式计算框架反而增加运维负担等行为数过了亿级再考虑换引擎那时候瓶颈也不在算法上了。3. 三路召回怎么落地ItemCF、TF-IDF 与热度兜底召回决定推荐列表的上限排序只决定这几百个候选怎么排。很多源码只做了协同过滤一路新书和长尾书永远出不来冷启动用户直接空白页。这套工程做了三路协同过滤管“和你口味相似的人还看了什么”内容召回管“和你看过的书主题相近”热度兜底保证任何情况下都有结果可出。3.1 ItemCF 相似度矩阵共现、归一化与 top-K 稀疏化图书侧的数量远大于用户侧做物品相似度比做用户相似度稳定得多而且相似度矩阵可以离线算好、增量更新线上只查表。这就是选 ItemCF 而不是矩阵分解的第一个理由可解释、可增量、不需要在线推理框架。第二个理由是图书的共现信号强用户看了 A 又看 B语义关联通常很明确。import numpy as np, pandas as pd from scipy.sparse import csr_matrix df pd.read_csv(ratings_20240601.csv) users {u: i for i, u in enumerate(df.user_id.unique())} books {b: i for i, b in enumerate(df.book_id.unique())} # 隐式反馈口径4 分及以上记 1其余记 0 vals (df.score 4).astype(np.float32) R csr_matrix((vals, (df.user_id.map(users), df.book_id.map(books))), shape(len(users), len(books))) # 图书-图书共现矩阵 C (R.T R).toarray() diag np.diag(C).copy() np.fill_diagonal(C, 0) # 余弦归一化抑制热门书把相似度整体拉高 S C / (np.sqrt(diag)[:, None] * np.sqrt(diag)[None, :] 1e-9) np.fill_diagonal(S, 0) # 只保留每本书 top-K 邻居控制内存与线上扫描成本 K 200 idx np.argpartition(-S, K, axis1)[:, :K] rows, cols, data [], [], [] for i in range(S.shape[0]): for j in idx[i]: if S[i, j] 0: rows.append(i); cols.append(j); data.append(S[i, j]) np.savez_compressed(itemcf_topk.npz, rowrows, colcols, valdata, booksnp.array(list(books.keys())))np.fill_diagonal(C, 0)这一步不能省。不做的话每本书和自己共现次数最高归一化之后自己跟自己的相似度恒为 1召回时第一条永远是用户刚看过的那本——这就是那份“推荐已读”问题的源头之一。argpartition取 top-K 而不是全排序是因为十万本书做全排序既慢又没必要。K 取 200 是经验值取值太小长尾书召不回来取值太大线上每个种子书要扫 200 个邻居二十个种子就是四千次打分接口延迟肉眼可见。取值大于 500 时建议改成倒排切分按种子书分片并行。注意R.T R在十万本书、千万条行为下会得到稠密的十万乘十万矩阵内存直接顶到几十 GB。真正上线前要先按热度裁剪图书池只保留有评分记录的书这批通常只占总量的两三成再算共现。别在本地拿全量数据硬跑这一点踩过就知道疼。3.2 内容召回中文图书标签的 TF-IDF 与一个分词坑内容召回解决的是协同过滤的两类盲区新入库的书没人评过冷启动用户没有行为。做法是把书名、分类、标签、简介拼成一段文本向量化之后算余弦相似度。import jieba, pandas as pd from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.preprocessing import normalize bks pd.read_csv(books.csv).fillna() bks[intro] bks.intro.str.slice(0, 300) # 简介截断头部信息密度最高 def seg(s): # 中文必须显式分词TfidfVectorizer 默认按空白切中文会整段成一个 token return .join(jieba.cut(str(s))) corpus (bks.title bks.category bks.tags.str.replace(,, ) bks.intro).map(seg) vec TfidfVectorizer(max_features20000, min_df2, ngram_range(1, 2)) X normalize(vec.fit_transform(corpus)) # L2 归一化后点积即余弦中文分词是这个环节最常见的翻车点。TfidfVectorizer默认的token_pattern处理中文会把整句话当成一个特征最后得到的相似度全是 0 或者全是很高看着像模型不行其实是根本没切开。用 jieba 先切好、空格拼接之后再喂进去是最省事的路子。参数上min_df2过滤只出现一次的词简介里的编辑套话基本都被清掉ngram_range(1,2)保留二元组能让“数据结构”和“数据结构 教材”这类组合被识别出来max_features20000是为了控制向量文件大小图书标签的词表通常在几万量级两万已经能覆盖头部。简介截断到 300 字是因为图书简介后半段大多是推荐语和作者生平对主题判断几乎没有贡献全量塞进去反而稀释了权重。内容相似度不需要全量存取每本书 top-50 就够剩下靠协同过滤补。3.3 三路融合与 MMR 打散Java 侧怎么把候选攒起来离线产出三份文件itemcf_topk.npz、content_topk.csv、hot_list.csv。Java 服务启动时全部加载到内存用MapLong, double[]或基本类型数组存放避免每条记录都 new 一个对象——这条在图书量上十万时直接影响 GC 表现。public ListLong recommend(long userId, int size) { MapLong, Double cand new HashMap(1024); // 1. 协同召回用最近 20 条正向行为当种子权重 1.0 for (long seed : recentPositive(userId, 20)) { neighbors(seed).forEach((bookId, sim) - cand.merge(bookId, sim * 1.0, Double::sum)); } // 2. 内容召回用最近 5 条行为的向量均值当用户画像权重 0.6 double[] profile profileOf(userId, 5); for (long bookId : contentTopN(profile, 100)) { cand.merge(bookId, contentScore(profile, bookId) * 0.6, Double::sum); } // 3. 热度兜底归一化后权重压到 0.1只保证有结果不抢戏 hotList(200).forEach((bookId, h) - cand.merge(bookId, h * 0.1, Double::sum)); // 4. 过滤已读/已购再按分数取候选池 cand.keySet().removeAll(readSet(userId)); ListMap.EntryLong, Double pool cand.entrySet().stream() .filter(e - !blocked(e.getKey())) .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(500) .toList(); return mmr(pool, size, 0.7); // 相关性 0.7 / 多样性 0.3 }三个权重不是拍脑袋定的顺序是先把协同权重钉在 1.0 作为基准再调内容权重最后加热度。内容权重从 0.3 试到 1.0一般 0.5~0.7 之间用户侧体感最好高于 1.0 之后推荐会明显“偏题”——主题都对但都是冷门书点击率反而掉。mmr(pool, size, 0.7)是最大边际相关性打散公式是每次选书时用λ * 相关性 - (1-λ) * 与已选集合的最大相似度重新打分。图书推荐里λ取 0.6~0.75小了推荐会变成大杂烩大了前三本之后全是同一作者同一系列。另外无论 MMR 怎么算都要硬性限制同一作者、同一分类最多出现 2 本规则比调参可靠。候选池limit(500)这个数也不是随便写的排序层如果用粗排加精排500 是够的如果只做规则排序200 就够再大只是白烧 CPU。4. 排序与评估离线指标怎么算才不作假调参最怕的是指标好看、线上没动静。图书推荐尤其容易这样因为行为稀疏评估集稍微切错一点NDCG 能虚高 30%。这一章说三件事指标怎么实现、数据集怎么切、参数按什么顺序动。4.1 PrecisionK、RecallK 与 NDCGK 的实现三个指标各有用途Precision 看列表前几条准不准Recall 看用户真实喜欢的有多少被捞回来NDCG 看顺序对不对。图书推荐首页只有一屏六本NDCG10 是最贴近体感的那个。import numpy as np def precision_at_k(rec, truth, k): return len(set(rec[:k]) truth) / k def recall_at_k(rec, truth, k): return len(set(rec[:k]) truth) / len(truth) if truth else 0.0 def ndcg_at_k(rec, truth, k): dcg sum(1 / np.log2(i 2) for i, b in enumerate(rec[:k]) if b in truth) idcg sum(1 / np.log2(i 2) for i in range(min(len(truth), k))) return dcg / idcg if idcg else 0.0实现本身没难度坑在分母。recall_at_k里len(truth)用的是测试集里该用户实际交互过的书数如果测试集切得太碎比如只留了 1 本Recall 会虚高到接近 1这个数没有任何参考意义。我一般要求测试集里每个用户至少留 3 本不够的用户整条剔除。另一个常被忽略的点是评估时要对全体用户取平均还是只对“有推荐结果的用户”取平均。只算有结果的用户指标会好看一大截但冷启动那部分用户被悄悄藏起来了。正确做法是分两段报全体用户的均值以及冷启动用户单独一段后者才是真实体感。4.2 时间切分把“未来信息”挡在训练集之外随机切分是最常见的泄漏源。用户在 6 月评了 A 书5 月的模型却“知道”这条行为算出来的相似度自然漂亮上线就崩。正确做法是按ts排序后切。# 按时间切前 80% 训练后 20% 测试 df df.sort_values(ts) cut int(len(df) * 0.8) train, test df.iloc[:cut], df.iloc[cut:] # 只保留在训练集里出现过的用户和书冷启动单独评估 seen_users set(train.user_id) test test[test.user_id.isin(seen_users)]切完之后还有一层过滤不能漏测试集里出现、但训练集里从没出现过的书任何模型都推不出来把它们算进 Recall 分母等于凭空扣分。评估前要把这批书剔掉或者单独统计成“新书曝光覆盖率”这一个独立指标。提示把训练集的时间上界和模型文件的生成时间一起写进文件名比如itemcf_20240601_20240531.npz。出问题时一眼就能看出线上加载的是哪天的模型、用了哪段数据比翻日志快得多。4.3 超参取值区间与调参顺序下面这张表是这类图书推荐工程里真正会动的参数起步值可以直接抄后面按业务体感微调。参数作用起步值调大后的影响ItemCF 邻居数 K每本书保留的相似书数200覆盖率升内存与召回耗时升最小共现次数过滤偶然共现5相似度更稳长尾覆盖下降正样本阈值隐式反馈口径4 分样本更干净数量明显减少召回权重 w_cf / w_cb / w_hot三路融合1.0 / 0.6 / 0.1热度权重超过 0.3 会明显同质化MMR λ相关性 vs 多样性0.7越大越相关越小越发散候选池大小排序前候选数500排序成本近似线性增长单作者/单分类上限硬性打散规则2 本大于 3 之后首页会显得重复调参顺序建议固定成先定样本口径阈值、窗口→ 再定邻居数 K → 再调三路权重 → 最后调 MMR。顺序反了会白跑很多轮因为样本口径一变前面调好的权重全部作废。每次只动一个参数离线指标和人工抽查各看一遍——只看指标不看列表很容易调出一个“数字漂亮但推的是同一批书”的模型。5. 排查清单推荐结果跑偏的五个典型现象线上问题基本都能归到这五类我把它们按出现频率排了序每条都带上定位手段。5.1 现象所有用户首页推的是同一批书现象很直观换几个账号登进去前六本几乎一样。原因是候选池被热度兜底吃掉了。协同召回没出结果用户行为太少、或者种子书没有邻居内容召回向量为空最后只剩热度那一路在贡献分数权重虽然只有 0.1但其他两路是 0排序结果自然全是热门书。另一个隐性原因是相似度矩阵只加载了头部图书长尾书查不到邻居等于静默失败。解决分三步先在召回层加埋点把三路各自贡献的候选数打出来看是哪一路归零再把热度兜底改成“仅在候选数小于 size 时启用”而不是无条件合并最后检查相似度文件的图书覆盖率低于 60% 就说明算矩阵时图书池裁得太狠需要补算。5.2 现象推荐接口 P99 从 80ms 涨到 1.2s先看是不是某次发布之后开始的。这类延迟突增九成是实时计算混进了请求链路有人在 Service 里直接遍历相似度矩阵算相似度或者每次请求都去数据库查一遍用户行为。排查顺序打点记录每路召回耗时、取书详情耗时、序列化耗时先定位是哪一段。如果确实是召回段把种子行为列表加本地缓存Caffeine过期时间 5 分钟相似度查询改成数组下标访问而不是 Map 查找批量取书用IN一次查完而不是循环单查。注意图书详情这类基础数据用本地缓存没问题但用户的已读列表不能缓存太久。用户刚评完分立刻刷新看到同一本书体验上比慢 200ms 更糟。5.3 现象离线 NDCG 0.4线上点击率没动离线指标不低但线上没反应优先怀疑两件事评估集泄漏或者线上根本没用到模型结果。前者按 4.2 的时间切分重跑一遍后者更常见——检查线上请求是否真的走到了推荐接口很多工程有降级开关配置里默认开着降级线上一直在返回热度列表模型跑得再好也没用。还有一种情况是推荐位太靠下。首页推荐模块在第三屏曝光本来就少点击率的分母被稀释模型再好也体现不出来。这类问题不是算法能解的得先调产品结构。5.4 现象服务重启后内存一路涨到 OOM相似度矩阵的存储方式决定内存曲线。如果代码里存的是MapLong, MapLong, Double十万本书、每本 200 个邻居光对象头就吃掉好几个 GB。再加上每次请求都新建临时 MapGC 压力更大。解决换成long[] keys加double[] vals的平行数组或者MapLong, double[]配long[]邻居 ID序列化用堆外或 mmap 读文件。另外 top-K 的 K 值也要回头看200 是给十万级图书定的图书量翻倍时 K 要相应压到 100 左右才能把内存守住。5.5 现象推荐列表里出现用户刚评过分的书过滤逻辑写了但没生效通常是这三个原因已读集合是异步写入缓存的请求时读到旧值过滤用的是book_id的Long包装类型比较比的是引用十万级 ID 一旦超过缓存范围就判不相等或者只过滤了主表没过滤用户在其他入口产生的行为。前两个最容易踩。Long比较必须用equals或先转longValue()这条在书 ID 上尤其隐蔽因为小 ID 在 Integer 缓存范围内时表现正常ID 一大就开始随机失效。稳妥做法是请求时强制回查一次近期行为走主键索引很快再在结果层做一次兜底过滤双保险比单点可靠。5.6 上线前的一套五分钟自检流程改完任何一环我都按这个顺序走一遍比直接发版省事拉一个新注册用户看首页有没有结果、是不是全是热门书。拉一个行为很多的老用户看列表里有没有已读书、同一作者是否超过 2 本。检查推荐接口的日志确认三路召回的候选数都大于 0。用测试集跑一遍 Precision10 和 NDCG10和上一版对比波动超过 15% 就停下来看样本。压测单接口 200 QPS看 P99 是否在 150ms 以内。6. 增量更新与冷启动让模型文件不停机换掉模型重训完要上线最省事的做法是重启服务但推荐服务通常挂在首页重启意味着几十秒的空白。这套源码里我改过一个点用双缓冲把模型切换做成原子替换。// 模型持有者读多写少用 volatile 保证可见性 public class ModelHolder { private volatile Model current Model.empty(); // 定时任务重训完调用整份替换不修改旧对象 public void swap(Model next) { if (next.bookIndex.size() current.bookIndex.size() * 0.8) { log.warn(新模型覆盖率骤降放弃切换); return; // 覆盖率跌破 80% 就回滚别切 } this.current next; log.info(model swapped, books{}, next.bookIndex.size()); } public Model get() { return current; } }关键在于整份替换而不是原地修改。请求线程持有的是替换前那个对象的引用它会把这一次请求算完新请求拿到的就是新对象中间没有加锁也没有中间态。切换前加一道覆盖率校验是因为重训脚本最常出的问题不是算错而是数据导出中断导致模型只覆盖了一半图书这种版本切上去长尾书的推荐会整片消失而且很难第一时间发现。冷启动这块我一般按三类用户分开处理用户类型判定条件策略完全新用户无任何行为分类热度榜 编辑精选按注册渠道分流有 1~4 条行为低于协同阈值内容召回为主权重 1.0协同直接跳过行为充足5 条以上三路融合正常权重第三类里还有个小技巧种子行为不要全用只取最近 20 条并且按行为类型加权收藏 1.0、评分 1.0、浏览 0.3。早期我用全量历史当种子结果一个两年前看了几十本考研书的用户首页推的全是考研资料而他现在买的是童书——兴趣是会漂移的时间衰减比什么都管用。最后说个教训。早期我调这个系统的时候只盯着离线 NDCG从 0.21 调到 0.38特别有成就感直接切了线上。结果第二天数据一看点击率掉了 8%。翻了三小时日志才发现新模型把长尾书推得太猛前六本里有四本是用户根本没听过的冷门书——离线评估集里这些书确实被“命中”了但那是因为测试集本身偏长尾和真实曝光分布完全不是一回事。从那以后我每次切模型都强制走一遍 5.6 那套自检流程另外再拉 20 个真实账号人工看一眼列表指标只当门槛不当结论。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询