ABP入门系列目录——学习Abp框架之实操演练

源码路径:Github-LearningMpaAbp


1. 引言

上一节我们讲解了如何创建微信公众号模块,这一节我们就继续跟进,来讲一讲公众号模块如何与系统进行交互。

微信公众号模块作为一个独立的web模块部署,要想与现有的【任务清单】进行交互,我们要想明白以下几个问题:

  1. 如何进行交互?

    ABP模板项目中默认创建了webapi项目,其动态webapi技术允许我们直接访问appservice作为webapi而不用在webapi层编写额外的代码。所以,自然而然我们要通过webapi与系统进行交互。
  2. 通过webapi与系统进行交互,如何确保安全?

    我们知道暴露的webapi如果不加以授权控制,就如同在大街上裸奔。所以在访问webapi时,我们需要通过身份认证来确保安全访问。
  3. 都有哪几种身份认证方式?

    第一种就是大家熟知的cookie认证方式;

    第二种就是token认证方式:在访问webapi之前,先要向目标系统申请令牌(token),申请到令牌后,再使用令牌访问webapi。Abp默认提供了这种方式;

    第三种是基于OAuth2.0的token认证方式:OAuth2.0是什么玩意?建议先看看OAuth2.0 知多少以便我们后续内容的展开。OAuth2.0认证方式弥补了Abp自带token认证的短板,即无法进行token刷新。

基于这一节,我完善了一个demo,大家可以直接访问http://shengjietest.azurewebsites.net/进行体验。

下面我们就以【通过webapi请求用户列表】为例看一看三种认证方式的具体实现。

2. Cookie认证方式

Cookie认证方式的原理就是:在访问webapi之前,通过登录目标系统建立连接,将cookie写入本地。下一次访问webapi的时候携带cookie信息就可以完成认证。

2.1. 登录目标系统

这一步简单,我们仅需提供用户名密码,Post一个登录请求即可。

我们在微信模块中创建一个WeixinController

public class WeixinController : Controller
{
private readonly IAbpWebApiClient _abpWebApiClient;
private string baseUrl = "http://shengjie.azurewebsites.net/";
private string loginUrl = "/account/login";
private string webapiUrl = "/api/services/app/User/GetUsers";
private string abpTokenUrl = "/api/Account/Authenticate";
private string oAuthTokenUrl = "/oauth/token";
private string user = "admin";
private string pwd = "123qwe"; public WeixinController()
{
_abpWebApiClient = new AbpWebApiClient();
}
}

其中IAbpWebApiClient是对HttpClient的封装,用于发送 HTTP 请求和接收HTTP 响应。

下面添加CookieBasedAuth方法,来完成登录认证,代码如下:

public async Task CookieBasedAuth()
{
Uri uri = new Uri(baseUrl + loginUrl);
var handler = new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.None, UseCookies = true }; using (var client = new HttpClient(handler))
{
client.BaseAddress = uri;
client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); var content = new FormUrlEncodedContent(new Dictionary<string, string>()
{
{"TenancyName", "Default"},
{"UsernameOrEmailAddress", user},
{"Password", pwd }
}); //获取token保存到cookie,并设置token的过期日期
var result = await client.PostAsync(uri, content); string loginResult = await result.Content.ReadAsStringAsync(); var getCookies = handler.CookieContainer.GetCookies(uri); foreach (Cookie cookie in getCookies)
{
_abpWebApiClient.Cookies.Add(cookie);
}
}
}

这段代码中有几个点需要注意:

  1. 指定HttpClientHandler属性UseCookie = true,使用Cookie;
  2. client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));用来指定接受的返回值;
  3. 使用FormUrlEncodedContent进行传参;
  4. 使用var getCookies = handler.CookieContainer.GetCookies(uri);获取返回的Cookie,并添加到_abpWebApiClient.Cookies的集合中,以便下次直接携带cookie信息访问webapi。

2.2. 携带cookie访问webapi

服务器返回的cookie信息在登录成功后已经填充到_abpWebApiClient.Cookies中,我们只需post一个请求到目标api即可。

public async Task<PartialViewResult> SendRequestBasedCookie()
{
await CookieBasedAuth();
return await GetUserList(baseUrl + webapiUrl);
} private async Task<PartialViewResult> GetUserList(string url)
{
try
{
var users = await _abpWebApiClient.PostAsync<ListResultDto<UserListDto>>(url); return PartialView("_UserListPartial", users.Items);
}
catch (Exception e)
{
ViewBag.ErrorMessage = e.Message;
} return null;
}

3. Token认证方式

Abp默认提供的token认证方式,很简单,我们仅需要post一个请求到/api/Account/Authenticate即可请求到token。然后使用token即可请求目标webapi。

但这其中有一个问题就是,如果token过期,就必须使用用户名密码重写申请token,体验不好。

3.1. 请求token

public async Task<string> GetAbpToken()
{
var tokenResult = await _abpWebApiClient.PostAsync<string>(baseUrl + abpTokenUrl, new
{
TenancyName = "Default",
UsernameOrEmailAddress = user,
Password = pwd
});
this.Response.SetCookie(new HttpCookie("access_token", tokenResult));
return tokenResult;
}

这段代码中我们将请求到token直接写入到cookie中。以便我们下次直接从cookie中取回token直接访问webapi。

3.2. 使用token访问webapi

从cookie中取回token,在请求头中添加Authorization = Bearer token,即可。

public async Task<PartialViewResult> SendRequest()
{
var token = Request.Cookies["access_token"]?.Value;
//将token添加到请求头
_abpWebApiClient.RequestHeaders.Add(new NameValue("Authorization", "Bearer " + token)); return await GetUserList(baseUrl + webapiUrl);
}

这里面需要注意的是,abp中配置app.UseOAuthBearerAuthentication(AccountController.OAuthBearerOptions);使用的是Bearer token,所以我们在请求weiapi时,要在请求头中假如Authorization信息时,使用Bearer token的格式传输token信息(Bearer后有一个空格!)。

4. OAuth2.0 Token认证方式

OAuth2.0提供了token刷新机制,当服务器颁发的token过期后,我们可以直接通过refresh_token来申请token即可,不需要用户再录入用户凭证申请token。

4.1. Abp集成OAuth2.0

在WebApi项目中的Api路径下创建Providers文件夹,添加SimpleAuthorizationServerProviderSimpleRefreshTokenProvider类。

其中SimpleAuthorizationServerProvider用来验证客户端的用户名和密码来颁发token;SimpleRefreshTokenProvider用来刷新token。

public class SimpleAuthorizationServerProvider : OAuthAuthorizationServerProvider, ITransientDependency
{
private readonly LogInManager _logInManager; public SimpleAuthorizationServerProvider(LogInManager logInManager)
{
_logInManager = logInManager;
} public override Task ValidateClientAuthentication(OAuthValidateClientAuthenticationContext context)
{
string clientId;
string clientSecret;
if (!context.TryGetBasicCredentials(out clientId, out clientSecret))
{
context.TryGetFormCredentials(out clientId, out clientSecret);
}
var isValidClient = string.CompareOrdinal(clientId, "app") == 0 &&
string.CompareOrdinal(clientSecret, "app") == 0;
if (isValidClient)
{
context.OwinContext.Set("as:client_id", clientId);
context.Validated(clientId);
}
else
{
context.SetError("invalid client");
} return Task.FromResult<object>(null);
} public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
{
var tenantId = context.Request.Query["tenantId"];
var result = await GetLoginResultAsync(context, context.UserName, context.Password, tenantId);
if (result.Result == AbpLoginResultType.Success)
{
//var claimsIdentity = result.Identity;
var claimsIdentity = new ClaimsIdentity(result.Identity);
claimsIdentity.AddClaim(new Claim(ClaimTypes.Name, context.UserName));
var ticket = new AuthenticationTicket(claimsIdentity, new AuthenticationProperties());
context.Validated(ticket);
}
} public override Task GrantRefreshToken(OAuthGrantRefreshTokenContext context)
{
var originalClient = context.OwinContext.Get<string>("as:client_id");
var currentClient = context.ClientId; // enforce client binding of refresh token
if (originalClient != currentClient)
{
context.Rejected();
return Task.FromResult<object>(null);
} // chance to change authentication ticket for refresh token requests
var newId = new ClaimsIdentity(context.Ticket.Identity);
newId.AddClaim(new Claim("newClaim", "refreshToken")); var newTicket = new AuthenticationTicket(newId, context.Ticket.Properties);
context.Validated(newTicket); return Task.FromResult<object>(null);
} private async Task<AbpLoginResult<Tenant, User>> GetLoginResultAsync(OAuthGrantResourceOwnerCredentialsContext context,
string usernameOrEmailAddress, string password, string tenancyName)
{
var loginResult = await _logInManager.LoginAsync(usernameOrEmailAddress, password, tenancyName); switch (loginResult.Result)
{
case AbpLoginResultType.Success:
return loginResult;
default:
CreateExceptionForFailedLoginAttempt(context, loginResult.Result, usernameOrEmailAddress, tenancyName);
//throw CreateExceptionForFailedLoginAttempt(context,loginResult.Result, usernameOrEmailAddress, tenancyName);
return loginResult;
}
} private void CreateExceptionForFailedLoginAttempt(OAuthGrantResourceOwnerCredentialsContext context,
AbpLoginResultType result, string usernameOrEmailAddress, string tenancyName)
{
switch (result)
{
case AbpLoginResultType.Success:
throw new ApplicationException("Don't call this method with a success result!");
case AbpLoginResultType.InvalidUserNameOrEmailAddress:
case AbpLoginResultType.InvalidPassword:
context.SetError(L("LoginFailed"), L("InvalidUserNameOrPassword"));
break;
// return new UserFriendlyException(("LoginFailed"), ("InvalidUserNameOrPassword"));
case AbpLoginResultType.InvalidTenancyName:
context.SetError(L("LoginFailed"), L("ThereIsNoTenantDefinedWithName", tenancyName));
break;
// return new UserFriendlyException(("LoginFailed"), string.Format("ThereIsNoTenantDefinedWithName{0}", tenancyName));
case AbpLoginResultType.TenantIsNotActive:
context.SetError(L("LoginFailed"), L("TenantIsNotActive", tenancyName));
break;
// return new UserFriendlyException(("LoginFailed"), string.Format("TenantIsNotActive {0}", tenancyName));
case AbpLoginResultType.UserIsNotActive:
context.SetError(L("LoginFailed"), L("UserIsNotActiveAndCanNotLogin", usernameOrEmailAddress));
break;
// return new UserFriendlyException(("LoginFailed"), string.Format("UserIsNotActiveAndCanNotLogin {0}", usernameOrEmailAddress));
case AbpLoginResultType.UserEmailIsNotConfirmed:
context.SetError(L("LoginFailed"), L("UserEmailIsNotConfirmedAndCanNotLogin"));
break;
// return new UserFriendlyException(("LoginFailed"), ("UserEmailIsNotConfirmedAndCanNotLogin"));
//default: //Can not fall to default actually. But other result types can be added in the future and we may forget to handle it
// //Logger.Warn("Unhandled login fail reason: " + result);
// return new UserFriendlyException(("LoginFailed"));
}
} private static string L(string name, params object[] args)
{
//return new LocalizedString(name);
return IocManager.Instance.Resolve<ILocalizationService>().L(name, args);
}
}
public class SimpleRefreshTokenProvider : IAuthenticationTokenProvider, ITransientDependency
{
private static ConcurrentDictionary<string, AuthenticationTicket> _refreshTokens = new ConcurrentDictionary<string, AuthenticationTicket>(); public Task CreateAsync(AuthenticationTokenCreateContext context)
{
var guid = Guid.NewGuid().ToString("N"); // maybe only create a handle the first time, then re-use for same client
// copy properties and set the desired lifetime of refresh token
var refreshTokenProperties = new AuthenticationProperties(context.Ticket.Properties.Dictionary)
{
IssuedUtc = context.Ticket.Properties.IssuedUtc,
ExpiresUtc = DateTime.UtcNow.AddYears(1)
};
var refreshTokenTicket = new AuthenticationTicket(context.Ticket.Identity, refreshTokenProperties); //_refreshTokens.TryAdd(guid, context.Ticket);
_refreshTokens.TryAdd(guid, refreshTokenTicket); // consider storing only the hash of the handle
context.SetToken(guid); return Task.FromResult<object>(null);
} public Task ReceiveAsync(AuthenticationTokenReceiveContext context)
{
AuthenticationTicket ticket;
if (_refreshTokens.TryRemove(context.Token, out ticket))
{
context.SetTicket(ticket);
} return Task.FromResult<object>(null);
} public void Create(AuthenticationTokenCreateContext context)
{
throw new NotImplementedException();
} public void Receive(AuthenticationTokenReceiveContext context)
{
throw new NotImplementedException();
}
}

以上两段代码我就不做过多解释,请自行走读。

紧接着我们在Api目录下创建OAuthOptions类用来配置OAuth认证。

public class OAuthOptions
{
/// <summary>
/// Gets or sets the server options.
/// </summary>
/// <value>The server options.</value>
private static OAuthAuthorizationServerOptions _serverOptions; /// <summary>
/// Creates the server options.
/// </summary>
/// <returns>OAuthAuthorizationServerOptions.</returns>
public static OAuthAuthorizationServerOptions CreateServerOptions()
{
if (_serverOptions == null)
{
var provider = IocManager.Instance.Resolve<SimpleAuthorizationServerProvider>();
var refreshTokenProvider = IocManager.Instance.Resolve<SimpleRefreshTokenProvider>();
_serverOptions = new OAuthAuthorizationServerOptions
{
TokenEndpointPath = new PathString("/oauth/token"),
Provider = provider,
RefreshTokenProvider = refreshTokenProvider,
AccessTokenExpireTimeSpan = TimeSpan.FromSeconds(30),
AllowInsecureHttp = true
};
}
return _serverOptions;
}
}

从中我们可以看出,主要配置了以下几个属性:

  • TokenEndpointPath :用来指定请求token的路由;
  • Provider:用来指定创建token的Provider;
  • RefreshTokenProvider:用来指定刷新token的Provider;
  • AccessTokenExpireTimeSpan :用来指定token过期时间,这里我们指定了30s,是为了demo 如何刷新token。
  • AllowInsecureHttp:用来指定是否允许http连接。

创建上面三个类之后,我们需要回到Web项目的Startup类中,配置使用集成的OAuth2.0,代码如下:

public void Configuration(IAppBuilder app)
{
//第一步:配置跨域访问
app.UseCors(CorsOptions.AllowAll); app.UseOAuthBearerAuthentication(AccountController.OAuthBearerOptions); //第二步:使用OAuth密码认证模式
app.UseOAuthAuthorizationServer(OAuthOptions.CreateServerOptions()); //第三步:使用Abp
app.UseAbp(); //省略其他代码
}

其中配置跨越访问时,我们需要安装Microsoft.Owin.CorsNuget包。

至此,Abp集成OAuth的工作完成了。

4.2. 申请OAuth token

我们在Abp集成OAuth配置的申请token的路由是/oauth/token,所以我们将用户凭证post到这个路由即可申请token:

public async Task<string> GetOAuth2Token()
{
Uri uri = new Uri(baseUrl + oAuthTokenUrl);
var handler = new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.None }; using (var client = new HttpClient(handler))
{
client.BaseAddress = uri;
client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); var content = new FormUrlEncodedContent(new Dictionary<string, string>()
{
{"grant_type", "password"},
{"username", user },
{"password", pwd },
{"client_id", "app" },
{"client_secret", "app"},
}); //获取token保存到cookie,并设置token的过期日期
var result = await client.PostAsync(uri, content);
string tokenResult = await result.Content.ReadAsStringAsync(); var tokenObj = (JObject)JsonConvert.DeserializeObject(tokenResult);
string token = tokenObj["access_token"].ToString();
string refreshToken = tokenObj["refresh_token"].ToString();
long expires = Convert.ToInt64(tokenObj["expires_in"]); this.Response.SetCookie(new HttpCookie("access_token", token));
this.Response.SetCookie(new HttpCookie("refresh_token", refreshToken));
this.Response.Cookies["access_token"].Expires = Clock.Now.AddSeconds(expires); return tokenResult;
}
}

在这段代码中我们指定的grant_type = password,这说明我们使用的是OAuth提供的密码认证模式。其中{"client_id", "app" }, {"client_secret", "app"}(搞过微信公众号开发的应该对这个很熟悉)用来指定客户端的身份和密钥,这边我们直接写死。

通过OAuth的请求的token主要包含四部分:

  • token:令牌
  • refreshtoken:刷新令牌
  • expires_in:token有效期
  • token_type:令牌类型,我们这里是Bearer

为了演示方便,我们直接把token信息直接写入到cookie中,实际项目中建议写入数据库。

4.3. 刷新token

如果我们的token过期了怎么办,咱们可以用refresh_token来重新获取token。

public async Task<string> GetOAuth2TokenByRefreshToken(string refreshToken)
{
Uri uri = new Uri(baseUrl + oAuthTokenUrl);
var handler = new HttpClientHandler() { AutomaticDecompression = DecompressionMethods.None, UseCookies = true }; using (var client = new HttpClient(handler))
{
client.BaseAddress = uri;
client.DefaultRequestHeaders.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); var content = new FormUrlEncodedContent(new Dictionary<string, string>()
{
{"grant_type", "refresh_token"},
{"refresh_token", refreshToken},
{"client_id", "app" },
{"client_secret", "app"},
}); //获取token保存到cookie,并设置token的过期日期
var result = await client.PostAsync(uri, content); string tokenResult = await result.Content.ReadAsStringAsync(); var tokenObj = (JObject)JsonConvert.DeserializeObject(tokenResult);
string token = tokenObj["access_token"].ToString();
string newRefreshToken = tokenObj["refresh_token"].ToString();
long expires = Convert.ToInt64(tokenObj["expires_in"]); this.Response.SetCookie(new HttpCookie("access_token", token));
this.Response.SetCookie(new HttpCookie("refresh_token", newRefreshToken));
this.Response.Cookies["access_token"].Expires = Clock.Now.AddSeconds(expires); return tokenResult;
}
}

这段代码较直接使用用户名密码申请token的差别主要在参数上,{"grant_type", "refresh_token"},{"refresh_token", refreshToken}

4.4. 使用token访问webapi

有了token,访问webapi就很简单了。

public async Task<ActionResult> SendRequestWithOAuth2Token()
{
var token = Request.Cookies["access_token"]?.Value;
if (token == null)
{
//throw new Exception("token已过期");
string refreshToken = Request.Cookies["refresh_token"].Value;
var tokenResult = await GetOAuth2TokenByRefreshToken(refreshToken);
var tokenObj = (JObject)JsonConvert.DeserializeObject(tokenResult);
token = tokenObj["access_token"].ToString();
} _abpWebApiClient.RequestHeaders.Add(new NameValue("Authorization", "Bearer " + token)); return await GetUserList(baseUrl + webapiUrl);
}

这段代码中,我们首先从cookie中取回access_token,若access_token为空说明token过期,我们就从cookie中取回refresh_token重新申请token。然后构造一个Authorization将token信息添加到请求头即可访问目标webapi。

5. 总结

本文介绍了三种不同的认证方式进行访问webapi,并举例说明。文章不可能面面俱到,省略了部分代码,请直接参考源码。若有纰漏之处也欢迎大家留言指正。

本文主要参考自以下文章:

使用OAuth打造webapi认证服务供自己的客户端使用

ABP中使用OAuth2(Resource Owner Password Credentials Grant模式)

Token Based Authentication using ASP.NET Web API 2, Owin, and Identity

ABP入门系列(16)——通过webapi与系统进行交互的更多相关文章

  1. ABP入门系列目录——学习Abp框架之实操演练

    ABP是"ASP.NET Boilerplate Project (ASP.NET样板项目)"的简称. ASP.NET Boilerplate是一个用最佳实践和流行技术开发现代WE ...

  2. ABP入门系列(15)——创建微信公众号模块

    ABP入门系列目录--学习Abp框架之实操演练 源码路径:Github-LearningMpaAbp 1. 引言 现在的互联网已不在仅仅局限于网页应用,IOS.Android.平板.智能家居等平台正如 ...

  3. ABP入门系列(9)——权限管理

    ABP入门系列目录--学习Abp框架之实操演练 源码路径:Github-LearningMpaAbp 完成了简单的增删改查和分页功能,是不是觉得少了点什么? 是的,少了权限管理.既然涉及到了权限,那我 ...

  4. ABP入门系列(8)——Json格式化

    ABP入门系列目录--学习Abp框架之实操演练 讲完了分页功能,这一节我们先不急着实现新的功能.来简要介绍下Abp中Json的用法.为什么要在这一节讲呢?当然是做铺垫啊,后面的系列文章会经常和Json ...

  5. ABP入门系列(10)——扩展AbpSession

    ABP入门系列目录--学习Abp框架之实操演练 源码路径:Github-LearningMpaAbp 一.AbpSession是Session吗? 1.首先来看看它们分别对应的类型是什么? 查看源码发 ...

  6. ABP入门系列(13)——Redis缓存用起来

    ABP入门系列目录--学习Abp框架之实操演练 源码路径:Github-LearningMpaAbp 1. 引言 创建任务时我们需要指定分配给谁,Demo中我们使用一个下拉列表用来显示当前系统的所有用 ...

  7. ABP入门系列(18)—— 使用领域服务

    ABP入门系列目录--学习Abp框架之实操演练 源码路径:Github-LearningMpaAbp 1.引言 自上次更新有一个多月了,发现越往下写,越不知如何去写.特别是当遇到DDD中一些概念术语的 ...

  8. ABP入门系列(20)——使用后台作业和工作者

    ABP入门系列目录--学习Abp框架之实操演练 源码路径:Github-LearningMpaAbp 1.引言 说到后台作业,你可能条件反射的想到BackgroundWorker,但后台作业并非是后台 ...

  9. ABP入门系列(12)——如何升级Abp并调试源码

    ABP入门系列目录--学习Abp框架之实操演练 源码路径:Github-LearningMpaAbp 1. 升级Abp 本系列教程是基于Abp V1.0版本,现在Abp版本已经升级至V1.4.2(截至 ...

随机推荐

  1. 专题:DUILIB Win32 透明效果

    Win32 透明效果相关基础知识 Layered Windows 分层窗口.这是Windows2000开始引入的概念,重新定义了窗口的Hit Testing方法,以前都是把窗口按rectangle的方 ...

  2. 1641: [Usaco2007 Nov]Cow Hurdles 奶牛跨栏

    1641: [Usaco2007 Nov]Cow Hurdles 奶牛跨栏 Time Limit: 5 Sec  Memory Limit: 64 MBSubmit: 424  Solved: 272 ...

  3. 摆脱printf的噩梦

    众所周知,printf是一个方便.直观.易写.变长参数的打印函数,但它有一个致命的缺陷,如下的语句将导致程序出现严重的运行时错误: printf("%s", 1); 然后程序中断, ...

  4. 记录Winform开发过程中遇到的情况

    前两天开发了个Winform操作Excel和数据库的一个小程序,把Winform的一些东西又给捡了起来,当中又学到了一些新的东西,特来写出来留作纪念. 一.CSKIN美化框架的使用 刚开始做的时候,发 ...

  5. css3 的 calc()函数在布局中的使用----头部高度固定,页面正好占满一屏

    最近项目遇到一个布局需求,头部高度固定,页面需要刚好占满一屏幕. 如下示意图: 方法:使用calc .wrap{ position: relative; margin-left: 24px; marg ...

  6. Docker(开课吧笔记)

    1.Docker基本概念 Docker运行在Linux,需要git技能 docker官网解析   来源于容器又不仅仅是容器,第一个版本基于LXC,远远超过容器概念   交付时拿到的是镜像,直接run运 ...

  7. python如何保证输入键入数字

    要求:python写一个要求用户输入数字,如果不是数字就一直循环要求输入,直到输入数字为止的代码 错误打开方式: while True: ten=input('Enter a number:') if ...

  8. swig编译GDAL的C#库时遇到的代码安全问题及解决方法

    之前一直用的是别人编译好的gdal库开发,今天自己编译了gdal的2.0.0版本,踩了不少坑,但总算解决了. 编译方法主要参考http://blog.csdn.net/liminlu0314/arti ...

  9. php中奖算法逻辑

    最近公司有两个活动, 一个是砸蛋活动, 另一个是转盘活动. 后台这边需要做接口进行对接,当用户在前台点击进行抽奖的时候,发送AJAX请求给后台,后台进行业务处理包括记录用户中奖信息,然后返回json格 ...

  10. [SinGuLaRiTy] 2017-03-27 综合性测试

    [SinGuLaRiTy-1013] Copyright (c) SinGuLaRiTy 2017. All Rights Reserved. 这是 三道 USACO 的题...... 第一题:奶牛飞 ...