#小程序云开发挑战赛#快速找工作-mrhuostudio
项目概要
应用场景:
- 为使用C#/dotnet技术的用户展现小程序开发的统一解决方案,让c#程序员更容易的开发微信小程序。
- 为用户在微信中快速找工作,汇聚各大招聘网站的招聘数据,提供集成的岗位查找。
用户人群:
- 各大高校学生、职场人士
- 使用dotnet技术的程序员
首先感谢官方提供了这次比赛。刚好最近看到腾讯云云函数SCF 推出Custom Runtime定制化运行功能,正在使用C#封装Custom Runtime的云函数,需要找一个场景来进行验证工作,当前微信小程序使用js 作为开发语言, 云开发的云函数也支持多种语言,但是他们都不支持C#,腾讯云 SCF Custom Runtime的云函数可以让更多的后端程序员投身全栈开发。
本次参加这次大赛的初衷是以大赛的要求来充分验证使用C#打造全场景的云原生应用开发,参加比赛的场景是使用小程序快速查找各大招聘网站的岗位,用户在小程序种输入岗位关键词和城市【支持全部城市岗位(不选城市)】,将查询条件提交给后端云函数向各大招聘网站提交查询请求,合并查询结果返回给小程序,同时将相同查询条件的请求使用Redis缓存到服务端。用户在小程序端可以保存他感兴趣的岗位,也可以在微信小程序种发起申请岗位(调用招聘网站的小程序,这一部分理论上技术可行,更多的是商务,因此目前未实现)。用户在小程序端的相关操作进行一个访问统计,用户登录和保存感兴趣岗位数据,以及用户的访问统计数据使用云开发的云函数 封装api和数据库保存数据, 同时将云开发的数据通过Custom Runtime的云函数进行封装成API 提供给 运营站点进行展示, 运营站点使用基于WebAssembly技术的前端框架blazor 进行开发,通过云开发部署到静态网站托管。
这个项目充分调用了小程序的能力,并且结合云开发的优势,同时扩展云开发的云函数,当前云开发的云函数不支持Custom Runtime,云开发的云函数也是基于腾讯云SCF封装。微信小程序可以在ios和android上运行,解决了移动App必须去兼容多端的接口,减少工作量,开发出来的小程序稳定,用完就走,方便用户使用。 云开发进一步的简化了微信小程序的开发,真正做到云原生应用开发,当前云开发不支持C#语言,本项目的主要目的就是展示使用C#语言在云开发的应用。
项目地址
本项目基于MIT协议在Github开源,开源地址https://github.com/dotnetcloudbase/findjobtclooud ,
后期在这项目基础上将SCF Custom Runtime 云函数的C#版本继续发展,本项目只实现httptrigger的MVP实现。
演示视频
https://v.qq.com/x/page/x3151ge7pvs.html
项目架构
功能架构
项目 功能上由小程序 和运营网站构成, 用户使用微信小程序快速找工作,相关的运营数据保存在云开发的云存储中,通过运营网站进行展示。

系统架构
综合使用云开发 和腾讯云云函数打造Serverless的云原生开发。

效果截图
小程序

默认界面是职位搜索界面,输入关键词后,点击键盘上的搜索按钮。小程序将向远程 scf 云函数发起请求,返回职位列表。如下:

可以看到相关职位都已经列出来,搜索框下面有两个下拉选择,分别是地区选择,和职位来源选择。不选择则返回所有地区和所有来源网站的职位信息。点击地区选择时:

这里可以切换地区,选中地区确认后,则只筛选指定地区的职位信息。这里可以看到左侧有个“定位”按钮,定位时,将根据 wx.getLocation API 获取用户当前经纬度,然后调用 scf 云函数返回用户城市。达到定位效果。重置按钮,则重置筛选条件,将城市设置为不限制。

来源列表里这里支持“猎聘网”、“智联招聘”、“前程无忧”、“Boos直聘”这4个来源。默认不限来源。

进入详情界面,可以看到最下方有 “分享”、“收藏”、“申请职位” 三个功能按钮。
点击分享,可以分享职位信息给朋友。
收藏按钮,可以收藏在我的收藏列表里,在“我的收藏”界面里展示。
申请职位按钮可以通过跳转别的网站小程序来实现,但是因为需要商务方面的工作,所以暂时没有实现申请职位。将来有机会,这个功能会实现的。
此详情页面设置了可以分享到朋友圈,点击右上角的 ”...“ 就可以分享到朋友圈。

“我的”界面里主要有收藏列表功能,用户首次使用时,头像那里有个“立即登录”,登录后,数据将通过上方所示云函数保存起来。

这里是我的收藏界面。
关于界面介绍写的很简单。不过这个小程序却不简单。能实现快速的筛选实时岗位信息,想要找工作的你,是否需要?
运营网站
运营网站首页

用户访问统计

申请职位统计

搜索关键词排行榜Top10

按城市搜索排行榜Top10

功能代码展示
SCF CustomRuntime 云函数
当前版本云开发不支持C# 编写云函数,SCF云函数支持通过Custom Runtime实现云函数,云开发本身也是基于SCF 云函数实现,因此这个功能主要展示Custom Runtime开发云函数,通过API网关发布给小程序使用。代码比较多,具体内容参见 《在腾讯云云函数计算上部署.NET Core 3.1》。
代码:https://github.com/dotnetcloudbase/findjobtclooud/tree/master/scf
namespace Yhd.FindJob
{
public class JobsHttpFunctionInvoker : HttpFunctionInvoker
{
private IJobsManager jobsManager;
private IAmapWebApi amapWebApi;
public JobsHttpFunctionInvoker(ILoggerFactory loggerFactory, IJobsManager manager, IAmapWebApi webApi) :
base(loggerFactory)
{
jobsManager = manager;
amapWebApi = webApi;
}
protected override async Task Handler(SCFContext context, APIGatewayProxyRequestEvent requestEvent)
{
if (requestEvent != null && requestEvent.RequestContext == null)
{
return new APIGatewayProxyResponseEvent()
{
ErrorCode = 410,
ErrorMessage = "event is not come from api gateway",
};
}
if (requestEvent != null)
{
if (requestEvent.Path != "/api/jobs/getjobs" && requestEvent.Path != "/api/jobs/getdetailsinfo" && requestEvent.Path != "/api/geocode/regeo")
{
return new APIGatewayProxyResponseEvent()
{
ErrorCode = 411,
ErrorMessage = "request is not from setting api path"
};
}
if (requestEvent.Path == "/api/jobs/getjobs" && requestEvent.HttpMethod.ToUpper() == "GET")
{
string sources = requestEvent.QueryString["sources"];
string city = requestEvent.QueryString["city"];
string searchKey = requestEvent.QueryString["searchkey"];
string pageIndex = requestEvent.QueryString["pageindex"];
var jobs = await jobsManager.GetJobsAsync(sources.Split('-').ToList(), city, searchKey, int.Parse(pageIndex));
var response = new APIGatewayProxyResponseEvent()
{
StatusCode = 200,
ErrorCode = 0,
ErrorMessage = "",
Body = JsonConvert.SerializeObject(jobs),
IsBase64Encoded = false,
Headers = new Dictionary()
};
response.Headers.Add("Content-Type", "application/json");
response.Headers.Add("Access-Control-Allow-Origin", "*");
return response;
}
if (requestEvent.Path == "/api/jobs/getdetailsinfo" && requestEvent.HttpMethod.ToUpper() == "GET")
{
string source = requestEvent.QueryString["source"];
string url = requestEvent.QueryString["url"];
var jobs = await jobsManager.GetDetailsInfo(source, url);
var response = new APIGatewayProxyResponseEvent()
{
StatusCode = 200,
IsBase64Encoded = false,
Headers = new Dictionary()
};
if (jobs == null)
{
response.ErrorCode = -1;
response.ErrorMessage = "user code exception caught";
}
else
{
response.ErrorCode = 0;
response.ErrorMessage = "";
response.Body = JsonConvert.SerializeObject(jobs);
}
response.Headers.Add("Content-Type", "application/json");
response.Headers.Add("Access-Control-Allow-Origin", "*");
return response;
}
if(requestEvent.Path == "/api/geocode/regeo" && requestEvent.HttpMethod.ToUpper() == "GET")
{
string location = requestEvent.QueryString["location"];
ReGeoParameter reGeoParameter = new ReGeoParameter()
{
Location = location,
Batch = false,
Output = "JSON",
Radius = 1000,
RoadLevel = 0,
Extensions = "base",
Poitype = string.Empty
};
var regeo = await amapWebApi.GetRegeoAsync(reGeoParameter);
var response = new APIGatewayProxyResponseEvent()
{
StatusCode = 200,
IsBase64Encoded = false,
Headers = new Dictionary()
};
if(regeo == null)
{
response.ErrorCode = -1;
response.ErrorMessage = "user code exception caught";
}
else
{
response.ErrorCode = 0;
response.ErrorMessage = "";
response.Body = string.IsNullOrEmpty(regeo.ReGeoCode.AddressComponent.City) ? regeo.ReGeoCode.AddressComponent.Province : regeo.ReGeoCode.AddressComponent.City;
}
response.Headers.Add("Content-Type", "application/json");
response.Headers.Add("Access-Control-Allow-Origin", "*");
return response;
}
}
return new APIGatewayProxyResponseEvent()
{
ErrorCode = 413,
ErrorMessage = "request is not correctly execute"
};
}
}
}
namespace Yhd.FindJobStat
{
///
/// 统计数据云开发数据库调用
///
public class StatsHttpFunctionInvoker : HttpFunctionInvoker
{
private static WxCloudApi _wxCloudApi;
public StatsHttpFunctionInvoker(ILoggerFactory loggerFactory, WxCloudApi wxCloudApi) :
base(loggerFactory)
{
_wxCloudApi = wxCloudApi;
}
///
/// URL mapping
///
private readonly Dictionary>> handlerMapper
= new Dictionary>>()
{
["GET /api/stat/pv"] = HandlerStatPV,
["GET /api/stat/apply"] = HandlerStatApplyButton,
["GET /api/stat/topsearch"] = HandlerStatTop10Search,
["GET /api/stat/topcity"] = HandlerStatTop10City
};
///
/// 根据结果构建统一返回值
///
///
///
///
private static APIGatewayProxyResponseEvent BuildCommonResponse(T responseModel)
{
var response = new APIGatewayProxyResponseEvent()
{
StatusCode = 200,
ErrorCode = 0,
ErrorMessage = "",
Body = JsonConvert.SerializeObject(responseModel),
IsBase64Encoded = false,
Headers = new Dictionary()
};
response.Headers.Add("Content-Type", "application/json");
response.Headers.Add("Access-Control-Allow-Origin", "*");
return response;
}
///
/// 调用 get-stat-pv 云函数
///
///
///
private static async Task HandlerStatPV(APIGatewayProxyRequestEvent requestEvent)
{
return BuildCommonResponse(await _wxCloudApi.InvokeCloudFunction("get-stat-pv"));
}
///
/// 调用 get-stat-apply 云函数
///
///
///
private static async Task HandlerStatApplyButton(APIGatewayProxyRequestEvent requestEvent)
{
return BuildCommonResponse(await _wxCloudApi.InvokeCloudFunction("get-stat-apply"));
}
///
/// 调用 get-stat-top-search 云函数
///
///
///
private static async Task HandlerStatTop10Search(APIGatewayProxyRequestEvent requestEvent)
{
return BuildCommonResponse(await _wxCloudApi.InvokeCloudFunction("get-stat-top-search"));
}
///
/// 调用 get-stat-top-city 云函数
///
///
///
private static async Task HandlerStatTop10City(APIGatewayProxyRequestEvent requestEvent)
{
return BuildCommonResponse(await _wxCloudApi.InvokeCloudFunction("get-stat-top-city"));
}
///
/// 拦截并分发请求
///
///
///
///
protected override async Task Handler(SCFContext context, APIGatewayProxyRequestEvent requestEvent)
{
if (requestEvent == null)
{
return new APIGatewayProxyResponseEvent()
{
ErrorCode = 413,
ErrorMessage = "request is not correctly execute"
};
}
if (requestEvent.RequestContext == null)
{
return new APIGatewayProxyResponseEvent()
{
ErrorCode = 410,
ErrorMessage = "event is not come from api gateway",
};
}
var path = $"{requestEvent.HttpMethod.ToUpper()} {requestEvent.Path.ToLower()}";
if (!handlerMapper.ContainsKey(path))
{
return new APIGatewayProxyResponseEvent()
{
ErrorCode = 411,
ErrorMessage = "request is not from setting api path"
};
}
return await handlerMapper[path](requestEvent);
}
}
}
云开发云函数
代码: https://github.com/dotnetcloudbase/findjobtclooud/tree/master/cloudfunctions
用户登录云函数主要是为了保存用户基本信息数据,如果用户信息已存在,则以最新数据覆盖旧数据,否则保存当前用户基本信息。
const cloud = require('wx-server-sdk')
cloud.init()
const db = cloud.database()
exports.main = async (event, context) => {
const wxContext = cloud.getWXContext()
const openId = wxContext.OPENID;
var user = await db.collection('users').where({
openId: openId
}).get();
if (user.data.length != 0) {
var userInfo = user.data[0];
var id = userInfo._id;
userInfo.avatarUrl = userInfo.avatarUrl || event.userInfo.avatarUrl;
userInfo.country = userInfo.country || event.userInfo.country;
userInfo.province = userInfo.province || event.userInfo.province;
userInfo.city = userInfo.city || event.userInfo.city;
userInfo.gender = typeof (userInfo.gender) === "number" ? userInfo.gender : event.userInfo.gender;
userInfo.language = userInfo.language || event.userInfo.language;
userInfo.nickName = userInfo.nickName || event.userInfo.nickName;
userInfo.unionId = wxContext.UNIONID || "";
delete userInfo._id;
await db.collection('users').doc(id).update({
data: userInfo
});
return userInfo;
} else {
var userInfo = event.userInfo;
userInfo.avatarUrl = userInfo.avatarUrl || null;
userInfo.country = userInfo.country || null;
userInfo.province = userInfo.province || null;
userInfo.city = userInfo.city || null;
userInfo.gender = typeof (userInfo.gender) === "number" ? userInfo.gender : 0;
userInfo.language = userInfo.language || "zh_CN";
userInfo.nickName = userInfo.nickName || null;
userInfo.openId = wxContext.OPENID;
userInfo.unionId = wxContext.UNIONID || "";
await db.collection('users').add({
data: userInfo
});
return userInfo;
}
}
获取收藏列表云函数列出了用户已添加的收藏列表。
const cloud = require('wx-server-sdk')
cloud.init()
const db = cloud.database()
exports.main = async (event, context) => {
return (await db.collection('favorites').where({
openId: cloud.getWXContext().OPENID
}).get()).data;
}
保存用户收藏,将职位名称,地区,薪资,来源公司名称都保存在收藏列表里。
const cloud = require('wx-server-sdk')
cloud.init()
const db = cloud.database()
exports.main = async (event, context) => {
const openId = cloud.getWXContext().OPENID;
var favorite = await db.collection('favorites').where({
openId: openId,
url: event.url
}).get();
if (favorite.data.length == 0) {
var favoriteInfo = {
openId: openId,
source: event.source,
url: event.url,
jobTitle: event.jobTitle,
area: event.area,
salary: event.salary,
jobCompany: event.jobCompany,
time: event.time,
logo: event.logo
};
await db.collection('favorites').add({
data: favoriteInfo
});
return true
}
await db.collection('favorites').doc(favorite.data[0]._id).remove();
return false
}
是否已收藏云函数就比较简单了,这里只是检查用户是否保存了职位详情 url。
const cloud = require('wx-server-sdk')
cloud.init()
const db = cloud.database()
exports.main = async (event, context) => {
const openId = cloud.getWXContext().OPENID;
var favorite = await db.collection('favorites').where({
openId: openId,
url: event.url
}).get();
return favorite.data.length > 0;
}
这里我们把统计数据归了类,用 type 来分类,type 一共有:"preview" 预览, "click" 点击详情, "location" 定位, "search" 搜索, "apply" 申请职位按钮, "favorite" 收藏, "shareToFriend" 分享给朋友, "shareToTimeline" ...
