认证服务
新建模块gulimall-auth-server
配置nacos,templates下有登录页面,注册页面,
新增视图映射
之前在controller中新增请求不做任何处理只返回对应视图也可以做到,但是这回导致controller里有空方法如:
@GetMapping("/login.html")
public String loginPage(){
return "login";
}
@Configuration
public class GuliMallWebConfig implements WebMvcConfigurer {
/**
* 视图映射:发送一个请求,直接跳转到一个页面
*/
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/login.html").setViewName("login");
registry.addViewController("/reg.html").setViewName("reg");
}
}
认证服务调用短信服务
@FeignClient("gulimall-third-party")
public interface ThirdPartyFeigenService {
@GetMapping("/sms/sendCode")
public R sendCode(@RequestParam("phone") String phone, @RequestParam("code") String code);
}
@Controller
public class LoginController {
@Autowired
ThirdPartFeignService thirdPartFeignService;
@GetMapping(value = "/sms/sendCode")
public R sendCode(@RequestParam("phone") String phone)
{
String code = UUID.randomUUID().toString().substring(0, 5);
thirdPartFeignService.sendCode(phone,code);
return R.ok();
}
}
此时的缺陷
接口写在前端js代码里,仍然可以被其他人拿来盗刷
由于发送验证码的接口暴露,为了防止恶意攻击,我们不能随意让接口被调用。
在redis中以phone-code将电话号码和验证码进行存储并将当前时间与code一起存储
如果调用时以当前phone取出的v不为空且当前时间在存储时间的60s以内,说明60s内该号码已经调用过,返回错误信息
60s以后再次调用,需要删除之前存储的phone-code
code存在一个过期时间,我们设置为10min,10min内验证该验证码有效
接口防刷功能
加了接口防刷功能:
1)先查询redis,是否超过60s,否则不允许发送短信
2)存入redis,过期时间10min,并且存入当前系统时间
引入redis相关依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Controller
public class LoginController {
@Autowired
ThirdPartyFeigenService thirdPartyFeigenService;
@Autowired
StringRedisTemplate redisTemplate;
@GetMapping("/sms/sendCode")
@ResponseBody
public R sendCode(@RequestParam("phone") String phone){
String redisCOde = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CHCHE_PREFIX + phone);
if (!StringUtils.isEmpty(redisCOde)){
long l =Long.parseLong(redisCOde.split("_")[1]) ;
//todo 1、接口防刷
if ((System.currentTimeMillis() - l) < 60000){
//60秒不能再发
return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(),BizCodeEnum.SMS_CODE_EXCEPTION.getMsg());
}
}
//2、验证码的再次校验 redis 存key-phone value-code
String code = UUID.randomUUID().toString().substring(0, 5);
String redisCode = code + "_" + System.currentTimeMillis();
//redis 缓存验证码 ,防止同一个手机号在60秒内再次发送验证码
redisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CHCHE_PREFIX+phone,redisCode,10, TimeUnit.MINUTES);
System.out.println("code++++++ " + code);
thirdPartyFeigenService.sendCode(phone,code);
return R.ok();
}
}
业务状态码
/***
* 错误码和错误信息定义类
* 1. 错误码定义规则为5为数字
* 2. 前两位表示业务场景,最后三位表示错误码。例如:100001。10:通用 001:系统未知异常
* 3. 维护错误码后需要维护错误描述,将他们定义为枚举形式
* 错误码列表:
* 10: 通用
* 001:参数格式校验
* 002 验证码获取频率太高
* 11: 商品
* 12: 订单
* 13: 购物车
* 14: 物流
*/
public enum BizCodeEnum {
/**
* 系统未知异常
*/
UNKNOWN_EXCEPTION(10000, "系统未知异常"),
/**
* 参数校验错误
*/
VALID_EXCEPTION(10001, "参数格式校验失败"),
SMS_CODE_EXCEPTION(10002, "验证码获取频率太高,稍后再试"),
PRODUCT_UP_EXCEPTION(11000,"商品上架异常");
private final int code;
private final String msg;
BizCodeEnum(int code, String msg) {
this.code = code;
this.msg = msg;
}
public int getCode() {
return code;
}
public String getMsg() {
return msg;
}
}
注册相关功能实现
后台JSR 303校验
在gulimall-auth-server服务中编写注册的主体逻辑
若JSR303校验未通过,则通过BindingResult封装错误信息,并重定向至注册页面
若通过JSR303校验,则需要从redis中取值判断验证码是否正确,正确的话通过会员服务注册(检查验证码、用户名、手机号 唯一),校验通过后,存储会员信息
会员服务调用成功则重定向至登录页,否则封装远程服务返回的错误信息返回至注册页面
注: RedirectAttributes可以通过session保存信息并在重定向的时候携带过去
/**
* 注册使用的vo
*/
@Data
public class UserRegisterVo {
@NotEmpty(message = "用户名必须提交")
@Length(min = 6, max = 19, message="用户名长度必须是6-18字符")
private String userName;
@NotEmpty(message = "密码必须填写")
@Length(min = 6,max = 18,message = "密码长度必须是6—18位字符")
private String password;
@NotEmpty(message = "手机号必须填写")
@Pattern(regexp = "^[1]([3-9])[0-9]{9}$", message = "手机号格式不正确")
private String phone;
@NotEmpty(message = "验证码必须填写")
private String code;
}
用户注册
/**
* todo 重定向携带数据,session原理,将数据放在session中
* 只要跳到下一个页面取出这个数据以后 session就会删掉
*
* todo 分布式下的session问题
* @param vo
* @param result
* @param redirectAttributes 模拟重定向携带数据
* @return
*/
@PostMapping("/regist")
public String regist(@Valid UserRegistVo vo, BindingResult result,
RedirectAttributes redirectAttributes){
if (result.hasErrors()){
Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField,FieldError::getDefaultMessage));
redirectAttributes.addFlashAttribute("errors",errors);
//session.setAttribute("errors",errors);
//Request method 'POST' not supported
//校验出错,转发到注册页
return "redirect:http://auth.gulimall.com/reg.html";
}
//注册 调用远程服务
//1、校验验证码
String code = vo.getCode();
String s = redisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CHCHE_PREFIX + vo.getPhone());
if (!StringUtils.isEmpty(s)){
if (code.equals(s.split("_")[0])){
//删除验证码
redisTemplate.delete(AuthServerConstant.SMS_CODE_CHCHE_PREFIX + vo.getPhone());
//验证码通过 远程服务进行注册
R r = memberFeignService.regist(vo);
if (r.getCode() == 0){
//成功
return "redirect:http://auth.gulimall.com/login.html";
}else {
Map<String, String> errors = new HashMap<>();
errors.put("msg",r.getData(new TypeReference<String>(){}));
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
}else {
Map<String, String> errors = new HashMap<>();
errors.put("code","验证码错误");
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
}else {
Map<String, String> errors = new HashMap<>();
errors.put("code","验证码错误");
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
//注册成功回到登录页
}
用户注册远程调用接口
@FeignClient("gulimall-member")
public interface MemberFeignService {
@PostMapping("/member/member/regist")
public R regist(@RequestBody UserRegistVo vo);
}
member服务注册方法
@PostMapping("/regist")
public R regist(@RequestBody MemberRegistVo vo){
try {
memberService.regist(vo);
}catch (PhoneExistException e){
return R.error(BizCodeEnum.PHONE_EXIST_EXCEPTION.getCode(),BizCodeEnum.PHONE_EXIST_EXCEPTION.getMsg());
}catch (UsernameExistException e){
return R.error(BizCodeEnum.USER_EXIST_EXCEPTION.getCode(),BizCodeEnum.USER_EXIST_EXCEPTION.getMsg());
}
return R.ok();
}
@Override
public void regist(MemberRegistVo vo) {
MemberEntity memberEntity = new MemberEntity();
//设置默认等级
MemberLevelEntity levelEntity = memberLevelDao.getDefaultLevel();
memberEntity.setLevelId(levelEntity.getId());
//检查用户名和手机是否唯一 为了让controller 能感知异常,异常机制
checkPhoneUnique(vo.getPhone());
checkUsernameUnique(vo.getUserName());
memberEntity.setMobile(vo.getPhone());
memberEntity.setUsername(vo.getUserName());
//密码加密存储
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
String encode = passwordEncoder.encode(vo.getPassword());
memberEntity.setPassword(encode);
this.baseMapper.insert(memberEntity);
}
异常机制
package com.atguigu.gulimall.member.exception;
public class PhoneExistException extends RuntimeException{
public PhoneExistException() {
super("手机号存在");
}
}
public class UsernameExistException extends RuntimeException{
public UsernameExistException() {
super("用户名存在");
}
}
MD5&盐值&BCrypt
1、MD5
Message Digest algorithm 5,信息摘要算法
压缩性:任意长度的数据,算出的MD5值长度都是固定的。
容易计算:从原数据计算出MD5值很容易。
抗修改性:对原数据进行任何改动,哪怕只修改1个字节,所得到的MD5值都有很大区别。
强抗碰撞:想找到两个不同的数据,使它们具有相同的MD5值,是非常困难的。
不可逆
2、加盐
通过生成随机数与MD5生成字符串进行组合
数据库同时存储MD5值与salt值。验证正确性时使用salt进行MD5即可
3、BCryptPasswordEncoder
和其他加密方式相比,BCryptPasswordEncoder有着它自己的优势所在,首先加密的hash值每次都不同,就像md5的盐值加密一样,只不过盐值加密用到了随机数,前者用到的是其内置的算法规则,毕竟随机数没有设合适的话还是有一定几率被攻破的。其次BCryptPasswordEncoder的生成加密存储串也有60位之多。最重要的一点是,md5的加密不是spring security所推崇的加密方式了,所以我们还是要多了解点新的加密方式。
BCryptPasswordEncoder每次加密相同的值,都会得到不同的密文
BCryptPasswordEncoder加密(encode)解密(matches)
加密encode
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String encode = bCryptPasswordEncoder.encode(vo.getPassword());
解密
尽管每次加密后的值都不同,但matches能够匹配
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//进行密码匹配,参数一是用户输入的明文密码,参数二是查询数据得到的对应用户的加密后的密码
boolean matches = passwordEncoder.matches(password, password1);
用户登录
//@requestbosy 不加是因为发的是 kv 数据不是json数据
@PostMapping("login")
public String login(UserLoginVo vo,RedirectAttributes redirectAttributes){
//远程登录
R r = memberFeignService.login(vo);
if (r.getCode() == 0){
return "redirect:http://gulimall.com";
}else {
Map<String,String> errors = new HashMap<>();
errors.put("msg",r.getData("msg",new TypeReference<String>(){}));
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/login.html";
}
}
远程调用接口
@PostMapping("/member/member/login")
public R login(@RequestBody UserLoginVo vo);
login方法
@PostMapping("/login")
public R login(@RequestBody MemberLoginVo vo){
MemberEntity entity = memberService.login(vo);
if (entity != null){
return R.ok();
}else {
return R.error(BizCodeEnum.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getCode(),BizCodeEnum.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getMsg());
}
}
@Override
public MemberEntity login(MemberLoginVo vo) {
String loginacct = vo.getLoginacct();
String password = vo.getPassword();
//1、去数据库 查询
MemberEntity memberEntity = baseMapper.selectOne(new QueryWrapper<MemberEntity>().eq("username", loginacct).or().eq("mobile", loginacct));
if (memberEntity == null){
//失败
return null;
}else {
//数据库密码
String passwordDb = memberEntity.getPassword();
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
boolean matches = encoder.matches(password, passwordDb);
if (matches){
return memberEntity;
}else {
return null;
}
}
}
社交登录 (OAuth2.0)
QQ、 微博、 github 等网站的用户量非常大, 别的网站为了简化自我网站的登陆与注册逻辑, 引入社交登陆功能;
步骤:
1) 、 用户点击 QQ 按钮
2) 、 引导跳转到 QQ 授权页
3) 、 用户主动点击授权, 跳回之前网页。
OAuth: OAuth(开放授权) 是一个开放标准, 允许用户授权第三方网站访问他们存储在另外的服务提供者上的信息, 而不需要将用户名和密码提供给第三方网站或分享他们数据的所有内容。
OAuth2.0: 对于用户相关的 OpenAPI(例如获取用户信息, 动态同步, 照片, 日志, 分享等) , 为了保护用户数据的安全和隐私, 第三方网站访问用户数据前都需要显式的向用户征求授权。
微博社交登录
进入微博开放平台 (审批时间过长放弃)使用gitee
https://gitee.com/api/v5/oauth_doc#/
OAuth2 获取 AccessToken 认证步骤
1. 授权码模式
- 应用通过 浏览器 或 Webview 将用户引导到码云三方认证页面上( GET请求 )
https://gitee.com/oauth/authorize?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code
2.获取访问令牌
-
应用服务器 或 Webview 使用 access_token API 向 码云认证服务器发送post请求传入 用户授权码 以及 回调地址( POST请求 )注:请求过程建议将 client_secret 放在 Body 中传值,以保证数据安全。
https://gitee.com/oauth/token?grant_type=authorization_code&code={code}&client_id={client_id}&redirect_uri={redirect_uri}&client_secret={client_secret}
3. 应用通过 access_token 访问 Open API 使用用户数据。
https://gitee.com/api/v5/user
逻辑
整合gitee 授权登录
获取授权码code 不返回直接去 /oauth2.0/gitee/success 接口 防止code被知道
@GetMapping("/oauth2.0/gitee/success")
public String Gitee(@RequestParam("code") String code) throws Exception {
Map<String,String> header = new HashMap<>();
Map<String,String> query = new HashMap<>();
Map<String,String> map = new HashMap<>();
map.put("client_id","a215bc6b7beb1376ff1657bd55b21cbbc266f307348ea71ad1744beb42c38693");
map.put("redirect_uri","http://auth.gulimall.com/oauth2.0/gitee/success");
map.put("client_secret","a5fd041267a1d28ee93374b4f94a11b9168379e0ac018d04f38e8f404f69fbe3");
map.put("grant_type","authorization_code");
map.put("code",code);
//1、根据code换取access_token
HttpResponse response = HttpUtils.doPost("https://gitee.com", "/oauth/token", "post", header, query, map);
//2、处理
if (response.getStatusLine().getStatusCode() == 200){
//获取到accessToken
String json = EntityUtils.toString(response.getEntity());
SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
//知道当前是哪个社交用户
//1)当前用户如果是第一次进网站,自动注册进来 (为当前社交用户生成一个会员信息账号,以后这个设计账号就对应指定的会员)
//登录或者注册
R r = memberFeignService.oauthLogin(socialUser);
if (r.getCode() == 0){
MemberResponseVo data = r.getData("data", new TypeReference<MemberResponseVo>() {
});
System.out.println("登录成功用户信息"+ data);
log.info("登录成功用户信息{}"+data);
//2、登录成功就回首页
return "redirect:http://gulimall.com";
}else {
return "redirect:http://auth.gulimall.com/login.html";
}
}else {
return "redirect:http://auth.gulimall.com/login.html";
}
}
远程设计用户登录方法
@PostMapping("/member/member/oauth2/login")
public R oauthLogin(@RequestBody SocialUser socialUser) throws Exception;
controller
@PostMapping("/oauth2/login")
public R oauthLogin(@RequestBody SocialUser socialUser) throws Exception {
MemberEntity entity = memberService.login(socialUser);
if (entity != null){
return R.ok().setData(entity);
}else {
return R.error(BizCodeEnum.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getCode(),BizCodeEnum.LOGINACCT_PASSWORD_INVAILD_EXCEPTION.getMsg());
}
}
@Override
public MemberEntity login(SocialUser socialUser) throws Exception {
Map<String,String> query = new HashMap<>();
query.put("access_token",socialUser.getAccessToken());
HttpResponse response = HttpUtils.doGet("https://gitee.com", "/api/v5/user", "get", new HashMap<String, String>(), query);
String json = EntityUtils.toString(response.getEntity());
JSONObject jsonObject = JSON.parseObject(json);
String id = jsonObject.getString("id");
String name = jsonObject.getString("name");
String gender = jsonObject.getString("gender");
String profileImageUrl = jsonObject.getString("avatar_url");
//具有登录和注册逻辑
String uid = id;
//1、判断当前用户是否注册过
MemberEntity memberEntity = this.baseMapper.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", uid));
if (memberEntity != null){
//说明以及注册过
MemberEntity update = new MemberEntity();
update.setId(memberEntity.getId());
update.setAccessToken(socialUser.getAccessToken());
update.setExpiresIn(socialUser.getExpiresIn());
baseMapper.updateById(update);
memberEntity.setAccessToken(socialUser.getAccessToken());
memberEntity.setExpiresIn(socialUser.getExpiresIn());
return memberEntity;
}else {
//2、没有查到当前社交用户对应的记录我们就需要注册一个
MemberEntity register = new MemberEntity();
try {
// Map<String, String> query = new HashMap<>();
// query.put("access_token", socialUser.getAccessToken());
// HttpResponse response = HttpUtils.doGet("https://gitee.com", "/api/v5/user", "get", new HashMap<String, String>(), query);
if (response.getStatusLine().getStatusCode() == 200) {
//查询成功
// String gender = jsonObject.getString("gender");
register.setUsername(name);
register.setNickname(name);
register.setCreateTime(new Date());
register.setGender("m".equals(gender) ? 1 : 0);
register.setHeader(profileImageUrl);
}
}catch (Exception e){}
//把用户信息插入到数据库中
register.setCreateTime(new Date());
register.setSocialUid(uid);
register.setAccessToken(socialUser.getAccessToken());
register.setExpiresIn(socialUser.getExpiresIn());
baseMapper.insert(register);
return register;
}
}
Session共享问题
session原理
session也是一种记录浏览器状态的机制,但与cookie不同的是,session是保存在服务器中。
由于http是无状态协议,当服务器存储了多个用户的session数据时,如何确认http请求对应服务器上哪一条session,相当关键。这也是session原理的核心内容。
分布式下session共享问题
分布式场景下相同服务
根据session原理可知,session信息是保存在服务器的,虽然是相同服务但是在不同服务器,也不能做到session共享
不同服务
因为获取session对象,是根据cookie中JSESSIONID来作为key获取session的,不同会话session ID不同,获取的session对象就会不同
Session共享问题解决–Session复制
Session共享问题解决–客户端存储
Session共享问题解决–hash一致性
方式一:利用用户ip地址来做负载均衡,使某一用户永远都访问的是同一台服务器
方式二:利用用户id来做负载均衡,使某一用户永远都访问的是同一台服务器
Session共享问题解决–统一存储
Session共享问题解决–不同服务,子域session共享
在存入session时jsessionid的作用域 domin提升至最大.比如auth.gulimall.com->.gulimall.com,那么gulimall.com及其下面的所有子域名都可以
拿到这个jsessionid,然后再去redis中查询对应的session信息,可以实现不同服务之间的session共享
相同服务之间的session共享使用,session存入redis即可解决问题,相同服务的域名是相同的jsessionid也是相同的
整合SpringSession
导入依赖
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
增加配置
spring.session.store-type=redis
server.servlet.session.timeout=30m
开启springsession
将该注解配置在主启动类上或者配置类上
@EnableRedisHttpSession //整合Redis作为session存储
默认使用jdk进行序列化,不方便阅读,建议修改为json
修改为json序列化,并放大作用域(自定义)
@Configuration
public class GulimallSessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
//放大作用域
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("GULISESSION");
cookieSerializer.setCookieMaxAge(60*60*24*7);
return cookieSerializer;
}
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
账号密码登录加上httpsession
//@requestbosy 不加是因为发的是 kv 数据不是json数据
@PostMapping("login")
public String login(UserLoginVo vo, RedirectAttributes redirectAttributes, HttpSession session){
//远程登录
R r = memberFeignService.login(vo);
if (r.getCode() == 0){
MemberResponseVo data = r.getData("data", new TypeReference<MemberResponseVo>() {
});
session.setAttribute(AuthServerConstant.LOGIN_USER,data);
return "redirect:http://gulimall.com";
}else {
Map<String,String> errors = new HashMap<>();
errors.put("msg",r.getData("msg",new TypeReference<String>(){}));
redirectAttributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/login.html";
}
}
gitee认证登录
@GetMapping("/oauth2.0/gitee/success")
public String Gitee(@RequestParam("code") String code, HttpSession session) throws Exception {
Map<String,String> header = new HashMap<>();
Map<String,String> query = new HashMap<>();
Map<String,String> map = new HashMap<>();
map.put("client_id","a215bc6b7beb1376ff1657bd55b21cbbc266f307348ea71ad1744beb42c38693");
map.put("redirect_uri","http://auth.gulimall.com/oauth2.0/gitee/success");
map.put("client_secret","a5fd041267a1d28ee93374b4f94a11b9168379e0ac018d04f38e8f404f69fbe3");
map.put("grant_type","authorization_code");
map.put("code",code);
//1、根据code换取access_token
HttpResponse response = HttpUtils.doPost("https://gitee.com", "/oauth/token", "post", header, query, map);
//2、处理
if (response.getStatusLine().getStatusCode() == 200){
//获取到accessToken
String json = EntityUtils.toString(response.getEntity());
SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
//知道当前是哪个社交用户
//1)当前用户如果是第一次进网站,自动注册进来 (为当前社交用户生成一个会员信息账号,以后这个设计账号就对应指定的会员)
//登录或者注册
R r = memberFeignService.oauthLogin(socialUser);
if (r.getCode() == 0){
MemberResponseVo data = r.getData("data", new TypeReference<MemberResponseVo>() {
});
//1、第一次使用session,命令浏览器保存卡号 jsession 这个cookie
session.setAttribute("loginUser",data);
System.out.println("登录成功用户信息"+ data);
log.info("登录成功用户信息{}"+data);
//2、登录成功就回首页
return "redirect:http://gulimall.com";
}else {
return "redirect:http://auth.gulimall.com/login.html";
}
}else {
return "redirect:http://auth.gulimall.com/login.html";
}
}
许雪里 开源项目
框架效果演示地址:https://gitee.com/xuxueli0323/xxl-sso
最重要的:中央认证服务器
核心:三个系统即使域名不一样,想办法给三个系统同步同一个用户的票据;
1)、中央认证服务器;ssoserver.com
2)、其他系统,想要登录去ssoserver.com登录,登录成功跳转回来
3)、只要有一个登录,其他都不用登录
4)、全系统统——个sso-sessionid;
多系统-单点登录
1、一处登录处处登录
2、一处退出处处退出
标签:code,06,String,认证,session,new,return,public,商城 From: https://www.cnblogs.com/flypigggg/p/16711318.html