资讯动态

协同过滤算法与用户推荐矩阵:电影推荐系统课程设计核心解析

发布时间:2026/10/25 19:04:16 来源:尧图企业网站定制
简介这是一份基于协同过滤算法与用户推荐矩阵的电影推荐系统Python源码适合计算机相关专业学生、教师及企业开发者用于课程设计、毕业设计或期末大作业也便于初学推荐系统者对照学习协同过滤原理与实现流程。资源共10个文件压缩包约21.55MB主要包含经典电影评分与电影信息数据、中间计算结果、3个Python功能脚本数据预处理、相似度计算、推荐生成以及说明文档整体目录结构清晰可独立运行并输出推荐结果。已有163人浏览学习项目代码经过测试可直接运行不仅可用于快速搭建原型还可在现有推荐矩阵基础上修改参数、换数据集或增加新的推荐策略。对于希望深入理解用户相似度计算、评分矩阵构建与实际推荐链路的学习者这份资源提供了从数据清洗到生成推荐的完整参考。1. 电影推荐系统课程设计协同过滤算法与用户推荐矩阵到底在解决什么问题课程设计里最容易被低估的项目就是这种「名字很长、代码很多」的推荐系统。很多同学把协同过滤当成查字典找几个和我相似的用户把他们看过而我没看过的电影推给我。真上手才发现评分矩阵怎么建、相似度算出来一堆 NaN、测试集和训练集混着用每一步都能让结果静悄悄翻车。这个标题做的事其实很具体读入用户对电影的历史评分构建用户-物品评分矩阵用协同过滤算法计算用户相似度或物品相似度最终生成一份给指定用户的电影推荐列表。标题里说的用户推荐矩阵指的就是从评分矩阵、相似度矩阵到预测矩阵这条完整链路。适合正在做课程设计、毕业设计以及刚入行推荐算法、想找个最小可用项目练手的从业者。2. 先把原理立住协同过滤的两条路线与用户推荐矩阵的构造逻辑2.1 基于用户与基于物品两条路线怎么选协同过滤算法按实现方式分两大类基于记忆的 Memory-based 与基于模型的 Model-based。标题既然没写矩阵分解、没写神经网络最常见、最符合课程设计预期的就是 Memory-based 协同过滤而它又能拆成两条路线User-based Collaborative Filtering 和 Item-based Collaborative Filtering。User-based 的核心逻辑是「人以群分」先找出和目标用户口味最接近的 k 个用户把这 k 个邻居看过、而目标用户没看过的电影按相似度加权打分分数高的排前面推荐出去。Item-based 的核心逻辑是「物以类聚」先算电影和电影之间的相似度然后拿目标用户已经看过的电影去匹配它们的「近亲电影」按用户的历史评分加权汇总。两条路线在实现上互为转置一个沿着评分矩阵的行找邻居另一个沿着列找邻居。维度User-basedItem-based相似度对象用户 vs 用户电影 vs 电影适合场景用户少、物品多的社区型产品用户多、物品相对稳定的电商、视频站实时性用户相似度变化快需频繁重算物品相似度相对稳定可离线算好缓存冷启动新用户无行为无法找邻居新电影无交互无法算相似度可解释性“和你口味相似的 A 也看过”“你看过 BB 和 C 风格接近”课程设计里用 ml-100k 数据集943 个用户对 1682 部电影产生 10 万条评分数据量两边都算得动不存在性能抉择。我一般会建议两个都实现答辩时对比展示两条路线的结果差异比只做一条路线更能体现你对算法的理解。也可以用最简单的一句口诀来判断从哪条路线先入手想看「推荐给谁」用 User-based 更直观想看「为什么推荐这部」用 Item-based 更容易解释。2.2 用户推荐矩阵从评分矩阵到预测矩阵的三层结构标题里的「用户推荐矩阵」不是一个黑匣子名词落到代码里就是三层数据结构理解了这三层整个项目的骨架就出来了。第一层是评分矩阵 Rating Matrix行是用户列是电影值是 1 到 5 的评分用户没看过的电影位置留空也就是 NaN绝不能填 0。第二层是相似度矩阵 Similarity MatrixUser-based 路线下它是一个 user × user 的方阵第 i 行第 j 列表示用户 i 和用户 j 的相似度Item-based 路线下则是 item × item 的方阵。第三层是预测矩阵 Prediction Matrix它也叫做用户推荐矩阵——行是用户列是候选电影值是模型预测出的用户可能给出的评分每一行取 Top-N 就得到推荐列表。为什么非要用矩阵形式而不是直接查表因为评分矩阵的每一行天然就是用户的向量表示可以用 numpy 直接做向量点积pandas 的 pivot_table 能把原始三元组user, movie, rating一次转成矩阵后续如果要换 scipy.sparse 稀疏矩阵、要接矩阵分解模型接口也不需要大改。矩阵还有一个隐藏好处稀疏度可以直接算出来数据稀疏到什么程度决定了你该不该用杰卡德相似度、该不该调大最小共同评分下限。注意一个细节pivot_table 转出来的矩阵里缺失值默认就是 NaN这一步看似不起眼却是后面所有相似度计算不出错的前提。很多翻车现场都是在这里用 fillna(0) 把缺失补成了 0结果余弦相似度分母算错、预测评分全偏向低分整个推荐列表变成热门电影榜。2.3 相似度度量余弦、皮尔逊、杰卡德的取舍与最小共同评分相似度怎么算直接决定邻居找得准不准。课程设计最常见的是余弦相似度公式是向量点积除以模长乘积。但余弦有一个毛病它对用户的评分习惯敏感。有人习惯给分 2 到 3有人习惯给分 4 到 5两个人品味一致余弦算出来却不高。皮尔逊相关系数本质上是中心化后的余弦——先减掉各自的均值再算余弦能抵消「手松手紧」的影响。杰卡德相似度只看交集比例适合 0/1 的看过/没看过隐式反馈但它丢掉了评分强度信息。import numpy as np def cosine_sim(vec_a, vec_b, min_common2): # 只取两边都有评分的维度其余位置忽略 mask ~np.isnan(vec_a) ~np.isnan(vec_b) if mask.sum() min_common: return 0.0 a, b vec_a[mask], vec_b[mask] denom np.linalg.norm(a) * np.linalg.norm(b) if denom 0.0: return 0.0 return np.dot(a, b) / denom这段代码里有三个关键点。mask 求的是两个向量共同评过分的维度这是协同过滤「只比较重叠部分」的核心min_common 是相似度计算的下限默认 2意思是两个用户至少共同看过 2 部电影才认为相似度有意义否则直接返回 0避免用一条共同评分就硬算相似度分母为 0 时返回 0防止除零得到 NaN。实际课程设计里皮尔逊就是在这个基础上先减均值再调用 cosine_sim代码可以复用同一套 mask 逻辑。至于用余弦还是皮尔逊没有绝对优劣建议把两个都实现最后看测试集 RMSE 谁更低就用谁这是血泪经验里最实用的判断方式。3. 把数据灌进矩阵MovieLens 读取、清洗与 user-item 评分矩阵构建3.1 数据源选型为什么课程设计首选 MovieLens 100K电影推荐系统的标准数据集是 GroupLens 的 MovieLens 系列。常用版本有 ml-100k、ml-1m 和 ml-latest-small课程设计我首选 ml-100k理由很实际它正好 10 万条评分、943 个用户、1682 部电影文件只有几 MBpandas 秒级加载不需要分布式、不需要采样而且自带按时间切分的 ua.base 和 ua.test 划分文件。ml-1m 数据量是它十倍跑 User-based 的两两相似度会明显变慢ml-latest-small 虽然量小但电影 ID 更新到较新版本老教程的字段处理方式不一定对得上。ml-100k 里的 u.data 是一个 Tab 分隔的文本文件四列分别是 user_id、movie_id、rating1 到 5、timestamp。u.item 存电影元数据第一列是 movie_id第二列是标题后面是类型标签。下面这段代码把评分读进来顺便看数据规模和缺失情况import pandas as pd # 列名一定要显式指定u.data 文件里没有表头 df pd.read_csv(ml-100k/u.data, sep\t, names[user_id, movie_id, rating, timestamp]) print(f评分总数: {len(df)}) print(f用户数: {df[user_id].nunique()}) print(f电影数: {df[movie_id].nunique()}) print(f评分分布:\n{df[rating].value_counts().sort_index()})这段代码的作用是把原始评分文件变成标准的三元组 DataFrame。参数 sep\t 指定分隔符names 手动给列起名因为原始文件没有表头不指定的话第一行数据会被当成列名导致解析错位。打印评分分布是为了检查数据质量正常 100K 评分中 3、4 分的占比远高于 1、2 分如果分布异常说明文件解析出错或数据源有问题。做这一步的时候顺便确认一下 Python 环境配好了没pandas 和 numpy 装了没有很多人卡在第一步不是代码问题而是 python 安装 时没把 pip 路径加进环境变量。3.2 用 pivot_table 构建评分矩阵一行代码但有三个隐藏细节评分矩阵的构建核心就一行 pivot_table但这一行后面藏着三个课程设计高频踩坑点。先看代码import numpy as np import pandas as pd # 确保评分是数值类型对同一对 user-movie 重复评分取均值 df[rating] pd.to_numeric(df[rating], errorscoerce) matrix df.pivot_table(indexuser_id, columnsmovie_id, valuesrating, aggfuncmean) # 打印矩阵尺寸和稀疏度 n_user, n_item matrix.shape sparsity 1 - matrix.notna().sum().sum() / (n_user * n_item) print(f矩阵形状: {matrix.shape}) print(f稀疏度: {sparsity:.2%}) print(f内存占用: {matrix.memory_usage(deepTrue).sum() / 1024:.1f} KB)三个细节逐个说。第一pivot_table 默认对重复索引取均值如果原始数据里同一个用户对同一部电影有多条评分aggfuncmean 会合并成一条而 errorcoerce 能兜底非数值脏数据。第二生成出来的矩阵行索引是 user_id列索引是 movie_id缺失值自动变成 NaN这一步不要画蛇添足去 fillna(0)0 在推荐系统里代表「用户打了 0 分」而不是「没看过」填进去会让后续余弦相似度把大量无效维度当成共同评分为 0。第三稀疏度算出来应该在 93% 左右这是 100K 数据集的正常水平——943 个用户乘 1682 部电影全满需要 158 万条评分实际只有 10 万条。了解稀疏度不是为了炫技而是为了决定后续相似度计算策略这种稀疏度下不可能存在两个用户把 1682 部电影全看过所以 min_common 必须设得足够小否则几乎找不到邻居。3.3 切分训练集和测试集先切分再建矩阵顺序错了就是数据泄漏推荐系统的评估和普通机器学习一样需要把数据切成训练集和测试集。但这里有一个极为隐蔽的坑很多人先把全量数据做成评分矩阵再随机抽矩阵里的一些格子当测试集。这样训练矩阵里已经包含了测试格子对应的用户和物品信息相似度计算阶段模型就已经「看过」测试评分了测试指标虚高答辩时老师一问就露馅。正确做法是先切分原始三元组再分别构建训练矩阵和测试矩阵from sklearn.model_selection import train_test_split # 按 80/20 随机切分固定随机种子保证结果可复现 train_df, test_df train_test_split(df, test_size0.2, random_state42) # 只用训练数据构建评分矩阵 train_matrix train_df.pivot_table( indexuser_id, columnsmovie_id, valuesrating, aggfuncmean) test_matrix test_df.pivot_table( indexuser_id, columnsmovie_id, valuesrating, aggfuncmean) print(训练集评分条数:, len(train_df)) print(测试集评分条数:, len(test_df))train_test_split 里的 test_size0.2 表示拿 20% 的评分当测试集random_state42 固定随机数种子保证每次运行切分结果一致这是课程设计报告里必须写清楚的参数。测试矩阵只用于评估它的作用是取出在测试集里出现、但在训练集里没有被预测过的 (user, movie) 对把这些真实评分和模型预测值做对比。这样的评估流程才是可信的。另外有一种更贴近真实场景的切分是按时间对 timestamp 排序取前 80% 当训练、后 20% 当测试模拟「用过去预测未来」的推荐场景。课程设计用随机切分就够如果想在报告里多写一笔可以两种都做对比。4. 核心代码实现基于用户的协同过滤与基于物品的协同过滤4.1 基于用户的协同过滤找邻居、加权评分、排序推荐User-based 协同过滤的完整实现可以拆成三步对每个候选电影找目标用户的 k 个相似邻居、用邻居评分做相似度加权平均、对所有候选电影分数排序取 Top-N。下面这段代码是可独立运行的完整版建议直接存成 cf_user.pyimport numpy as np import pandas as pd def cosine_sim(vec_a, vec_b, min_common2): mask ~np.isnan(vec_a) ~np.isnan(vec_b) if mask.sum() min_common: return 0.0 a, b vec_a[mask], vec_b[mask] denom np.linalg.norm(a) * np.linalg.norm(b) if denom 0.0: return 0.0 return np.dot(a, b) / denom def find_similar_users(user_id, matrix, k10, min_common2): target matrix.loc[user_id].values sims [] for other_id in matrix.index: if other_id user_id: continue other matrix.loc[other_id].values sim cosine_sim(target, other, min_common) sims.append((other_id, sim)) # 相似度从高到低排序只保留正相似邻居 sims.sort(keylambda x: x[1], reverseTrue) return [item for item in sims if item[1] 0][:k] def predict_user_based(user_id, movie_id, matrix, k10, min_common2): if user_id not in matrix.index or movie_id not in matrix.columns: return np.nan watched matrix.loc[user_id, movie_id] if not np.isnan(watched): return watched neighbors find_similar_users(user_id, matrix, k, min_common) numerator, denominator 0.0, 0.0 for neighbor_id, sim in neighbors: rating matrix.loc[neighbor_id, movie_id] if np.isnan(rating): continue # 邻居没看过这部电影就不参与加权 numerator sim * rating denominator sim # 邻居全没看过时回退到该电影全局平均分 if denominator 0.0: return np.nanmean(matrix[movie_id]) return numerator / denominator def recommend_for_user(user_id, matrix, k10, top_n10): if user_id not in matrix.index: return None watched matrix.loc[user_id].dropna().index candidates [mid for mid in matrix.columns if mid not in watched] scores {} for movie_id in candidates: pred predict_user_based(user_id, movie_id, matrix, k) if not np.isnan(pred): scores[movie_id] pred ranked sorted(scores.items(), keylambda x: x[1], reverseTrue)[:top_n] return ranked代码逻辑的关键在于find_similar_users 里对每个用户做一次余弦相似度只保留相似度大于 0 的邻居再取前 k 个避免负相似度邻居把预测分数拉低。predict_user_based 里先判断目标用户是否已经看过这部电影看过就直接返回真实评分而不是再预测一次。加权平均部分denominator 累计的是邻居相似度之和等于给相似度高的邻居更大话语权这是 User-based 最简单的评分聚合方式。最后回退到全局平均分是为了兜底——候选电影的邻居可能恰好全都没看过这时候返回 NaN 会导致推荐列表缺项回退后至少能保住召回。参数方面k 是邻居数课程设计常用 10 到 20k 太小只受少数几个用户影响、方差大k 太大把相似度低的人拉进来、精度下降min_common 是最小共同评分数量100K 数据下 2 到 5 均可。运行一次完整预测需要遍历所有候选电影100K 数据下大概几十秒可接受。4.2 基于物品的协同过滤换个方向做相似度计算Item-based 与 User-based 的差别在于相似度对象从用户行变成电影列邻居集合从相似用户变成目标用户已经看过电影的各路相似电影。还有一点细节Item-based 推荐通常用「调整后的余弦相似度」——计算电影相似度时先把每个用户的评分减去该用户自己的平均分消除用户评分习惯偏差。核心实现如下def adjusted_cosine_sim(col_a, col_b, user_means, min_common2): # 减去用户均值后再算余弦消除用户打分习惯差异 a col_a - user_means b col_b - user_means mask ~np.isnan(a) ~np.isnan(b) if mask.sum() min_common: return 0.0 a, b a[mask], b[mask] denom np.linalg.norm(a) * np.linalg.norm(b) if denom 0.0: return 0.0 return np.dot(a, b) / denom def predict_item_based(user_id, movie_id, matrix, k10, min_common2): if user_id not in matrix.index or movie_id not in matrix.columns: return np.nan if not np.isnan(matrix.loc[user_id, movie_id]): return matrix.loc[user_id, movie_id] # 用户已看过的电影及评分是 Item-based 的“历史素材” user_ratings matrix.loc[user_id].dropna() if len(user_ratings) 0: return np.nan user_means matrix.loc[user_id].mean(skipnaTrue) target_col matrix[movie_id].values scores [] for other_id, rating in user_ratings.items(): if other_id movie_id: continue other_col matrix[other_id].values sim adjusted_cosine_sim(target_col, other_col, matrix.mean(axis1, skipnaTrue).values, min_common) scores.append((sim, rating)) scores.sort(keylambda x: x[0], reverseTrue) neighbors [s for s in scores if s[0] 0][:k] if not neighbors: return np.nanmean(matrix[movie_id]) numerator sum(sim * rating for sim, rating in neighbors) denominator sum(sim for sim, _ in neighbors) if denominator 0: return np.nanmean(matrix[movie_id]) return numerator / denominator实现里的核心差异点要重点解释。adjusted_cosine_sim 接收三个参数两列电影评分向量和每个用户的平均分它先把评分数值减去对应用户的平均分再做余弦这一步是 Item-based 精度比朴素余弦高的关键。predict_item_based 里对目标用户只遍历 ta 已经看过的电影每一部电影根据相似度加权贡献一个预测分数权重是电影相似度值是该用户给那部电影的评分。这等价于在说你很喜欢 A 电影而 B 电影和 A 很像那预测你也会喜欢 B。从工程角度看Item-based 有一个天然优势电影相似度矩阵可以离线一次性算好存起来线上推荐时直接查表而 User-based 的用户相似度会因为新评分不断变化需要频繁重算。课程设计可以提前算好物品相似度矩阵再写一个函数直接取相似度体验一下两种架构的效率差别。4.3 参数设置k 值、min_common、相似度阈值到底怎么调参数建议范围100K 课设推荐值调小的影响调大的影响k邻居数5 到 5010 或 20方差大结果依赖个别用户混入低相似度噪声精度下降min_common2 到 102 或 5相似度计算脆弱易出错覆盖率下降很多用户找不到邻居相似度阈值0 到 0.20保留全部正相似邻居太少覆盖率下降top_n5 到 2010推荐列表太短推荐列表尾部精度下降调参的方法是固定其他参数、单变量扫一遍。比如 k 分别取 5、10、15、20、30看测试集 RMSE 的变化曲线课程设计报告里画一条折线图比空谈「k 取 10 效果最好」有说服力得多。min_common 的调法类似用 1 到 10 扫一遍观察相似度有效率和覆盖率的变化min_common1 会让两个用户只共同看过一部电影就强算相似度结果极不可靠min_common 过大又会让新用户、冷门用户彻底没有邻居。相似度阈值我这里建议直接设 0原因很简单负相似度邻居的评分即使加进去也是反向拉低预测值直接剔除比调阈值简单而且省掉了「阈值取 0.05 还是 0.1」的玄学争论。5. 协同过滤避坑指南五处让推荐结果悄悄翻车的细节5.1 测试集评分被 fillna(0) 污染RMSE 虚低还查不出来现象预测函数跑完RMSE 只有 0.7 左右看起来效果极好但推荐列表全是热门电影答辩时一问细节就心虚。原因构建矩阵时用了 fillna(0)把「没评分」和「评了 0 分」混为一谈。余弦相似度计算时大量空白位置被当成 0 参与点积等于人为制造了稀疏维度上的伪相似度更致命的是预测函数里把没评过的格子也当成「评了 0 分」的真值测试集评估误差被大幅稀释。解决矩阵里保留 NaN相似度计算用 mask 跳过无评分维度预测评分时只对真正存在评分的测试条目计算误差。5.2 两个用户没有共同看过的电影余弦相似度直接除零现象某些用户之间相似度算出 NaN邻居列表为空推荐结果回退成全局热门榜冷门用户的结果完全没个性。原因余弦相似度公式里分母是两个向量的模长乘积而两个用户没有任何共同评分时mask 过滤后两个向量全是 NaNnp.linalg.norm 对全 NaN 向量返回 NaN继续运算把 NaN 传染给整个相似度矩阵。解决在余弦函数开头判断mask.sum() min_common就直接返回 0.0从源头阻断 NaN 的产生同时分母判断是否为 0。这两个保护缺一不可前者保证「没有足够重叠就不认邻居」后者防止模长恰好为 0 的边界。5.3 训练集和测试集没分开数据泄漏让评估分数成摆设现象测试集 RMSE 特别漂亮但随机抽几部电影看推荐原因发现推荐的电影和目标用户已经在测试集里评过 1 分的电影高度重合。原因先做完整矩阵再切分训练阶段计算用户相似度时已经看到了测试集评分模型相当于带着答案进考场。解决严格按「先切分原始三元组 → 训练矩阵和测试矩阵各自 pivot → 用训练矩阵做预测 → 和测试矩阵比对」的顺序执行。用 sklearn 的 train_test_split 或按时间戳排序切分都行关键是 pivot 动作必须在切分之后。5.4 新用户和新电影在矩阵里没有位置predict 直接 KeyError现象把推荐函数接到 Web 后端或新数据上时传入一个刚注册的用户 ID程序直接抛 KeyError接口崩溃。原因训练矩阵是固定的不在矩阵行索引里的新用户、不在列索引里的新电影用 .loc 取值时自然报错。解决预测函数开头加守卫if user_id not in matrix.index or movie_id not in matrix.columns: return np.nan上层推荐函数对返回 NaN 的候选电影做兜底——回退到该电影全局平均分或者直接推荐热门电影。冷启动在工业界也是难题课程设计阶段能用回退策略处理就算合格不要硬套相似度算法。5.5 评分矩阵内存爆炸稀疏存储能救回一个数量级现象换用更大数据集后pivot_table 构建矩阵时进程直接被系统杀死或者把相似度矩阵存成 CSV 后文件大得打不开。原因pivot_table 默认生成稠密 DataFrameml-100k 是 943×1682 才十几 MB扩大到 ml-1m 就是几千乘几千稠密存储开销随用户数和电影数乘积增长而实际评分占比通常不到 5%。解决用 scipy.sparse 的 csr_matrix 保存评分矩阵只存非零评分。稀疏矩阵有两个好处一是内存占用从 O(m×n) 降到 O(非零评分数量)二是矩阵乘法可以直接调用稀疏矩阵的 dot 方法一次算完全部用户相似度比 for 循环两两计算快几十倍。课程设计用好这个报告能多写一节「工程化优化」。6. 离线评估与进阶调优把课设从能跑变成能用课程设计交一个能跑的代码只是及格线真正拉开差距的是评估方法和进阶优化。先看评估指标怎么落到代码里。评分预测类任务用 RMSE推荐列表任务用 PrecisionK 和 RecallK两个维度都要覆盖from sklearn.metrics import mean_squared_error def evaluate_rmse(test_df, train_matrix, predict_func, k10): actuals, preds [], [] for row in test_df.itertuples(): pred predict_func(row.user_id, row.movie_id, train_matrix, k) if not np.isnan(pred): actuals.append(row.rating) preds.append(pred) return np.sqrt(mean_squared_error(actuals, preds))这段代码只对测试集里能成功预测出的条目统计误差预测为 NaN 的样本直接跳过因为那是覆盖不到的死角。RMSE 大概在 0.9 到 1.1 之间属于正常范围用随机猜测模拟的基线是 1.5 以上低于 0.9 要警惕是不是发生了数据泄漏。再进一步用留一法验证推荐列表对每个用户隐藏一条真实评分用剩下数据训练看 Top-K 推荐列表里有没有把隐藏那条电影推回来命中比例就是 Hit Rate。这个方法比 RMSE 更贴近真实使用感受——用户关心的不是预测分准不准而是推荐列表里有没有想看的片子。课程设计里做一张 k 从 5 到 30 的 Hit Rate 折线图配合 RMSE 曲线一起分析就能看出 k 太大虽然覆盖率上去了但列表精度会掉。进阶方向有两个值得写进报告一是引入矩阵分解做 baseline 对比用 surprise 库的 SVD 跑一遍同样数据和协同过滤的 RMSE 对比这是答辩时展示「我知道两种流派差异」的最好证据二是把物品相似度矩阵提前算好、序列化缓存到磁盘线上推荐时直接查表这一步能让推荐速度从秒级降到毫秒级属于工程上性价比最高的优化。我自己当年做课设时也犯过先建全量矩阵再切分的错测试分数好看得离谱答辩老师一句话就问穿了后来老老实实按先切分再建矩阵的流程重跑分数真实了但报告里多了一份「对数据泄漏的分析」反而成了加分项。希望这份实现思路和踩坑记录能帮你把课设做得既跑得通、也讲得清。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑