|
|
|
package auth
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
|
|
|
"html/template"
|
|
|
|
"math/rand"
|
|
|
|
"strconv"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"gitlab.fjmaimaimai.com/mmm-go/gocomm/identity/uid"
|
|
|
|
|
|
|
|
"opp/internal/repository"
|
|
|
|
"opp/models"
|
|
|
|
"opp/protocol"
|
|
|
|
s_im "opp/services/im"
|
|
|
|
s_sms "opp/services/sms"
|
|
|
|
|
|
|
|
"github.com/astaxie/beego"
|
|
|
|
"gitlab.fjmaimaimai.com/mmm-go/gocomm/common"
|
|
|
|
"gitlab.fjmaimaimai.com/mmm-go/gocomm/pkg/log"
|
|
|
|
"gitlab.fjmaimaimai.com/mmm-go/gocomm/pkg/redis"
|
|
|
|
comm_time "gitlab.fjmaimaimai.com/mmm-go/gocomm/time"
|
|
|
|
)
|
|
|
|
|
|
|
|
type IAuthService interface {
|
|
|
|
Login(request *protocol.LoginRequest) (rsp *protocol.LoginResponse, err error)
|
|
|
|
AccessToken(request *protocol.AccessTokenRequest) (rsp *protocol.AccessTokenResponse, err error)
|
|
|
|
RefreshToken(request *protocol.RefreshTokenRequest) (rsp *protocol.RefreshTokenResponse, err error)
|
|
|
|
UpdateDevice(request *protocol.UpdateDeviceRequest) (rsp *protocol.UpdateDeviceResponse, err error)
|
|
|
|
CheckToken(request *protocol.CheckTokenRequest) (rsp *protocol.CheckTokenResponse, err error)
|
|
|
|
CheckUuid(request *protocol.CheckUuidRequest) (rsp *protocol.CheckUuidResponse, err error)
|
|
|
|
SmsCode(request *protocol.SmsCodeRequest) (rsp *protocol.SmsCodeResponse, err error)
|
|
|
|
}
|
|
|
|
|
|
|
|
type AuthService struct{}
|
|
|
|
|
|
|
|
func assertImplement() {
|
|
|
|
var _ IAuthService = (*AuthService)(nil)
|
|
|
|
}
|
|
|
|
|
|
|
|
var (
|
|
|
|
//服务
|
|
|
|
sms s_sms.ISmsService = &s_sms.YunPianSmsService{}
|
|
|
|
ConfigRepository repository.IConfigRepository = &repository.ConfigRepository{}
|
|
|
|
//仓储
|
|
|
|
UserRepository repository.IUserRepository = &repository.UserRepository{}
|
|
|
|
)
|
|
|
|
|
|
|
|
//登录
|
|
|
|
func (s *AuthService) Login(request *protocol.LoginRequest) (rsp *protocol.LoginResponse, err error) {
|
|
|
|
var (
|
|
|
|
user *models.Users
|
|
|
|
userInfo *models.UserInfo
|
|
|
|
result bool
|
|
|
|
checkImResponse *protocol.CheckImResponse
|
|
|
|
)
|
|
|
|
user, err = UserRepository.GetUsersByMobile(request.Phone)
|
|
|
|
if err != nil {
|
|
|
|
log.Error(err)
|
|
|
|
err = protocol.NewErrWithMessage(2020, err) //账号不存在
|
|
|
|
return
|
|
|
|
}
|
|
|
|
switch request.GrantType {
|
|
|
|
case protocol.LoginTypePassPord:
|
|
|
|
if strings.Compare(user.Password, request.PassWord) == 0 {
|
|
|
|
goto Success
|
|
|
|
} else {
|
|
|
|
err = protocol.NewErrWithMessage(2021, err) //登录密码错误
|
|
|
|
return
|
|
|
|
}
|
|
|
|
break
|
|
|
|
case protocol.LoginTypeSmdcode:
|
|
|
|
if result, err = CheckSmsCode(request.Phone, request.Code, protocol.SmsLoginCode); result && err == nil {
|
|
|
|
goto Success
|
|
|
|
} else {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
default:
|
|
|
|
err = fmt.Errorf("grantType error")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
Success:
|
|
|
|
{
|
|
|
|
userInfo, err = UserRepository.GetUserInfoByMobile(request.Phone)
|
|
|
|
if err != nil {
|
|
|
|
log.Error(err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if userInfo.Auth == "" {
|
|
|
|
userInfo.Auth = uid.NewV1().StringNoDash()
|
|
|
|
}
|
|
|
|
if checkImResponse, err = CheckIm(&protocol.CheckImRequest{
|
|
|
|
Uid: fmt.Sprintf("%v", userInfo.Id),
|
|
|
|
Uname: userInfo.Uname,
|
|
|
|
Icon: userInfo.Icon,
|
|
|
|
IsCreated: userInfo.ImToken != "",
|
|
|
|
}); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if checkImResponse != nil && checkImResponse.ImToken != "" {
|
|
|
|
userInfo.ImToken = checkImResponse.ImToken
|
|
|
|
}
|
|
|
|
if userInfo.CsAccount == 0 {
|
|
|
|
userInfo.CsAccount = imGetRandomCSAccount()
|
|
|
|
}
|
|
|
|
userInfo.AuthExp = time.Now().Add(time.Second * protocol.TokenExpire)
|
|
|
|
if err = UserRepository.UpdateUserInfoById(userInfo); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
rsp = &protocol.LoginResponse{AuthCode: userInfo.Auth}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
func (s *AuthService) Login1(request *protocol.LoginRequest) (rsp *protocol.LoginResponse, err error) {
|
|
|
|
var logintype ILoginAuth
|
|
|
|
switch request.GrantType {
|
|
|
|
case protocol.LoginTypePassPord:
|
|
|
|
logintype = NewLoginByPassword(request.Phone, request.PassWord)
|
|
|
|
case protocol.LoginTypeSmdcode:
|
|
|
|
logintype = NewLoginBySms(request.Phone, request.Code)
|
|
|
|
default:
|
|
|
|
err = fmt.Errorf("grantType error:%s", request.GrantType)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
var useridentity *UserIdentity
|
|
|
|
useridentity, err = logintype.LoginAuth()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
rsp = &protocol.LoginResponse{
|
|
|
|
AuthCode: useridentity.Auth,
|
|
|
|
}
|
|
|
|
if len(useridentity.Auth) == 0 {
|
|
|
|
useridentity.Auth = uid.NewV1().StringNoDash()
|
|
|
|
}
|
|
|
|
if len(useridentity.Imtoken) == 0 {
|
|
|
|
//usercreate 最多重试俩次
|
|
|
|
for i := 0; i < 2; i++ {
|
|
|
|
v := s_im.UserCreate{
|
|
|
|
Accid: fmt.Sprintf("%s", useridentity.Uid),
|
|
|
|
Name: useridentity.Uname,
|
|
|
|
Icon: useridentity.Icon,
|
|
|
|
}
|
|
|
|
tokenInfo, err := s_im.ParseUserCreate(v)
|
|
|
|
if err != nil {
|
|
|
|
log.Error("s_im.ParseUserCreate err:", err)
|
|
|
|
}
|
|
|
|
if err == nil {
|
|
|
|
if tokenInfo.Code == 200 {
|
|
|
|
useridentity.Imtoken = tokenInfo.Info.Token
|
|
|
|
// 跳出
|
|
|
|
break
|
|
|
|
} else {
|
|
|
|
log.Error("s_im.ParseUserCreate response code:", tokenInfo.Code)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
} else {
|
|
|
|
//userupdateunifo
|
|
|
|
for i := 0; i < 2; i++ {
|
|
|
|
v := s_im.UserUpdateUinfo{
|
|
|
|
Accid: fmt.Sprintf("%s", useridentity.Accid),
|
|
|
|
Name: useridentity.Uname,
|
|
|
|
Icon: useridentity.Icon,
|
|
|
|
}
|
|
|
|
imRsp, err := s_im.ParseUserUpdateUinfo(v)
|
|
|
|
if err != nil {
|
|
|
|
log.Error("s_im.ParseUserUpdateUinfo err:", err)
|
|
|
|
}
|
|
|
|
if err == nil {
|
|
|
|
if imRsp.Code == 200 {
|
|
|
|
break
|
|
|
|
} else {
|
|
|
|
log.Error("s_im.ParseUserUpdateUinfo response code:", imRsp.Code)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
//更新设备信息
|
|
|
|
func (s *AuthService) UpdateDevice(request *protocol.UpdateDeviceRequest) (rsp *protocol.UpdateDeviceResponse, err error) {
|
|
|
|
return nil, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
//获取accessToken
|
|
|
|
func (s *AuthService) AccessToken(request *protocol.AccessTokenRequest) (rsp *protocol.AccessTokenResponse, err error) {
|
|
|
|
var (
|
|
|
|
userInfo *models.UserInfo
|
|
|
|
)
|
|
|
|
_, err = ConfigRepository.GetCfgClient(request.ClientId, request.ClientSecret)
|
|
|
|
if err != nil {
|
|
|
|
err = protocol.NewErrWithMessage(101, err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
userInfo, err = UserRepository.GetUserInfoByAuthCode(request.AuthCode)
|
|
|
|
if err != nil {
|
|
|
|
err = protocol.NewErrWithMessage(4139, err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
userInfo.AccessToken = uid.NewV1().StringNoDash()
|
|
|
|
userInfo.RefreshToken = uid.NewV1().StringNoDash()
|
|
|
|
if err = UserRepository.UpdateUserInfoById(userInfo); err != nil {
|
|
|
|
log.Error(err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
//valid token
|
|
|
|
rsp = &protocol.AccessTokenResponse{
|
|
|
|
RefreshToken: userInfo.RefreshToken,
|
|
|
|
AccessToken: userInfo.AccessToken,
|
|
|
|
ExpiresIn: protocol.TokenExpire,
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
//刷新token
|
|
|
|
func (s *AuthService) RefreshToken(request *protocol.RefreshTokenRequest) (rsp *protocol.RefreshTokenResponse, err error) {
|
|
|
|
var (
|
|
|
|
userInfo *models.UserInfo
|
|
|
|
)
|
|
|
|
_, err = ConfigRepository.GetCfgClient(request.ClientId, request.ClientSecret)
|
|
|
|
if err != nil {
|
|
|
|
protocol.NewErrWithMessage(101, err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
userInfo, err = UserRepository.GetUserInfoByRefreshToken(request.RefreshToken)
|
|
|
|
if err != nil {
|
|
|
|
protocol.NewErrWithMessage(4139, err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
userInfo.AccessToken = uid.NewV1().StringNoDash()
|
|
|
|
if err = UserRepository.UpdateUserInfoById(userInfo); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
rsp = &protocol.RefreshTokenResponse{
|
|
|
|
AccessToken: userInfo.AccessToken,
|
|
|
|
RefreshToken: userInfo.RefreshToken,
|
|
|
|
ExpiresIn: protocol.TokenExpire,
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
//刷新token loginType mobile im web
|
|
|
|
//func refreshToken(request *protocol.RefreshTokenRequest) (rsp *protocol.Access, err error) {
|
|
|
|
// if request.Uid == 0 {
|
|
|
|
// return
|
|
|
|
// }
|
|
|
|
// return nil, nil
|
|
|
|
//}
|
|
|
|
|
|
|
|
//检查token有效性
|
|
|
|
func (s *AuthService) CheckToken(request *protocol.CheckTokenRequest) (rsp *protocol.CheckTokenResponse, err error) {
|
|
|
|
var (
|
|
|
|
userInfo *models.UserInfo
|
|
|
|
)
|
|
|
|
if len(request.Token) == 0 {
|
|
|
|
err = common.NewErrorWithMsg(4141, "token not empty")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
userInfo, err = models.GetUserInfoByToken(request.Token)
|
|
|
|
if err != nil {
|
|
|
|
err = protocol.NewErrWithMessage(4141, err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
rsp = &protocol.CheckTokenResponse{
|
|
|
|
UserInfo: userInfo,
|
|
|
|
IsValid: true,
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
//检查uuid 是否重复
|
|
|
|
func (s *AuthService) CheckUuid(request *protocol.CheckUuidRequest) (rsp *protocol.CheckUuidResponse, err error) {
|
|
|
|
var (
|
|
|
|
logUuid *models.LogUuid
|
|
|
|
)
|
|
|
|
if len(request.Uuid) == 0 {
|
|
|
|
err = common.NewErrorWithMsg(4142, "uuid not empty")
|
|
|
|
}
|
|
|
|
logUuid, err = models.GetLogUuidByUuid(request.Uuid)
|
|
|
|
if err == nil && logUuid != nil {
|
|
|
|
err = common.NewErrorWithMsg(4142, "uuid not valid")
|
|
|
|
}
|
|
|
|
models.AddLogUuid(&models.LogUuid{Uuid: request.Uuid})
|
|
|
|
rsp = &protocol.CheckUuidResponse{}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
//短信验证码 T
|
|
|
|
func (s *AuthService) SmsCode(request *protocol.SmsCodeRequest) (rsp *protocol.SmsCodeResponse, err error) {
|
|
|
|
var (
|
|
|
|
value, key, msgContent string
|
|
|
|
smsInfo *protocol.SmsInfo
|
|
|
|
)
|
|
|
|
msgContent = `【买买买信息科技】{{.Code}}({{.AppName}}手机验证码,请完成验证),如非本人操作,请忽略本短信`
|
|
|
|
switch request.SendType {
|
|
|
|
case protocol.SmsLoginCode:
|
|
|
|
case protocol.SmsChangeMobile:
|
|
|
|
default:
|
|
|
|
err = common.NewErrorWithMsg(2, "send_type error.")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
key = request.SendType
|
|
|
|
//check user phone exists
|
|
|
|
if !redis.Hexists(key, request.Phone) {
|
|
|
|
smsInfo = &protocol.SmsInfo{
|
|
|
|
CreateTime: time.Now().Unix(),
|
|
|
|
}
|
|
|
|
goto Send
|
|
|
|
} else {
|
|
|
|
if value, err = redis.Hget(key, request.Phone); err != nil {
|
|
|
|
log.Error(err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if err = json.Unmarshal([]byte(value), &smsInfo); err != nil {
|
|
|
|
log.Error(err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
//第二天重置
|
|
|
|
if smsInfo.LastTime < comm_time.GetUnixTimeByYyyymmdd() {
|
|
|
|
smsInfo.Count = 0
|
|
|
|
smsInfo.CreateTime = time.Now().Unix()
|
|
|
|
}
|
|
|
|
if smsInfo.Count > 100 { //TODO:limit send time
|
|
|
|
return
|
|
|
|
}
|
|
|
|
goto Send
|
|
|
|
}
|
|
|
|
Send:
|
|
|
|
{
|
|
|
|
smsInfo.Code = common.RandomStringWithChars(6, string(protocol.Nums))
|
|
|
|
smsInfo.LastTime = time.Now().Unix()
|
|
|
|
smsInfo.ErrorCount = 0
|
|
|
|
//Todo Lock
|
|
|
|
smsInfo.Count += 1
|
|
|
|
if err = redis.Hset(key, request.Phone, common.AssertJson(smsInfo), -1); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
tp := template.New("sms_code")
|
|
|
|
tp.Parse(msgContent)
|
|
|
|
buf := bytes.NewBuffer(nil)
|
|
|
|
tp.Execute(
|
|
|
|
buf,
|
|
|
|
map[string]string{
|
|
|
|
"Code": smsInfo.Code,
|
|
|
|
"AppName": beego.BConfig.AppName,
|
|
|
|
})
|
|
|
|
request.Content = buf.String()
|
|
|
|
err = sms.Send(request)
|
|
|
|
rsp = &protocol.SmsCodeResponse{
|
|
|
|
Code: smsInfo.Code,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
//验证短信验证码 T
|
|
|
|
func CheckSmsCode(phone, code, sendType string) (result bool, err error) {
|
|
|
|
var (
|
|
|
|
value string
|
|
|
|
smsInfo *protocol.SmsInfo
|
|
|
|
)
|
|
|
|
result = false
|
|
|
|
if value, err = redis.Hget(sendType, phone); err != nil { //protocol.SmsLoginCode
|
|
|
|
err = common.NewErrorWithMsg(1009, "smscode expire")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if err = json.Unmarshal([]byte(value), &smsInfo); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if smsInfo.ErrorCount >= 5 {
|
|
|
|
err = common.NewErrorWithMsg(1011, "smscode over error times")
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if (smsInfo.LastTime + 60*5) < time.Now().Unix() {
|
|
|
|
err = common.NewErrorWithMsg(1009, fmt.Sprintf("smscode expire %v < %v", (smsInfo.LastTime+60*5), time.Now().Unix()))
|
|
|
|
goto Fail
|
|
|
|
}
|
|
|
|
if smsInfo.Code == code {
|
|
|
|
result = true
|
|
|
|
return
|
|
|
|
} else {
|
|
|
|
err = common.NewErrorWithMsg(1012, "smscode error")
|
|
|
|
goto Fail
|
|
|
|
}
|
|
|
|
Fail:
|
|
|
|
{
|
|
|
|
smsInfo.ErrorCount += 1
|
|
|
|
if err = redis.Hset(sendType, phone, common.AssertJson(smsInfo), -1); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
func CheckIm(request *protocol.CheckImRequest) (rsp *protocol.CheckImResponse, err error) {
|
|
|
|
var ()
|
|
|
|
if beego.BConfig.RunMode != "prod" {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
rsp = &protocol.CheckImResponse{}
|
|
|
|
if !request.IsCreated {
|
|
|
|
if err = imUserCreate(request, rsp); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
if err = imUserInfoUpdate(request, rsp); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if err = imUserRefreshToken(request, rsp); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
//create
|
|
|
|
func imUserCreate(request *protocol.CheckImRequest, rsp *protocol.CheckImResponse) (err error) {
|
|
|
|
var (
|
|
|
|
param s_im.UserCreate = s_im.UserCreate{
|
|
|
|
Accid: request.Uid,
|
|
|
|
Name: request.Uname,
|
|
|
|
Icon: request.Icon,
|
|
|
|
}
|
|
|
|
resp []byte
|
|
|
|
out s_im.UserTokenResult
|
|
|
|
)
|
|
|
|
if resp, err = s_im.DefaultImClient.Call(param); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if err = json.Unmarshal(resp, &out); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if out.Code != 200 || (out.Info.Accid != request.Uid) {
|
|
|
|
return s_im.ErrorFailCall
|
|
|
|
}
|
|
|
|
rsp.ImToken = out.Info.Token
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
//update user info
|
|
|
|
func imUserInfoUpdate(request *protocol.CheckImRequest, rsp *protocol.CheckImResponse) (err error) {
|
|
|
|
var (
|
|
|
|
param s_im.UserUpdateUinfo = s_im.UserUpdateUinfo{
|
|
|
|
Accid: request.Uid,
|
|
|
|
Name: request.Uname,
|
|
|
|
Icon: request.Icon,
|
|
|
|
}
|
|
|
|
resp []byte
|
|
|
|
out s_im.BaseResp
|
|
|
|
)
|
|
|
|
if resp, err = s_im.DefaultImClient.Call(param); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if err = json.Unmarshal(resp, &out); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if out.Code != 200 {
|
|
|
|
return s_im.ErrorFailCall
|
|
|
|
}
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
//refresh token
|
|
|
|
func imUserRefreshToken(request *protocol.CheckImRequest, rsp *protocol.CheckImResponse) (err error) {
|
|
|
|
var (
|
|
|
|
param s_im.UserRefreshToken = s_im.UserRefreshToken{
|
|
|
|
Accid: request.Uid,
|
|
|
|
}
|
|
|
|
resp []byte
|
|
|
|
out s_im.UserTokenResult
|
|
|
|
)
|
|
|
|
if resp, err = s_im.DefaultImClient.Call(param); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if err = json.Unmarshal(resp, &out); err != nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if out.Code != 200 || (out.Info.Accid != request.Uid) {
|
|
|
|
return s_im.ErrorFailCall
|
|
|
|
}
|
|
|
|
rsp.ImToken = out.Info.Token
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// 获取客服id
|
|
|
|
func imGetRandomCSAccount() (acid int64) {
|
|
|
|
kefus, err := models.GetUsersKefu()
|
|
|
|
if err != nil {
|
|
|
|
log.Error(err)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if len(kefus) <= 0 {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
index := rand.Intn(len(kefus))
|
|
|
|
acid, err = strconv.ParseInt(kefus[index].Accid, 10, 64)
|
|
|
|
if err != nil {
|
|
|
|
log.Error(err)
|
|
|
|
}
|
|
|
|
return acid
|
|
|
|
} |
...
|
...
|
|