家庭组逻辑导致支付失败
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 8m11s
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 8m11s
This commit is contained in:
parent
9f4d71770b
commit
ce6351368d
@ -17,7 +17,7 @@ Logger: # 日志配置
|
||||
MySQL:
|
||||
Addr: 127.0.0.1:3306 # MySQL地址
|
||||
Username: root # MySQL用户名 (与创建的用户一致)
|
||||
Password: password # MySQL密码 (换成之前生成的随机密码)
|
||||
Password: rootpassword # MySQL密码 (换成之前生成的随机密码)
|
||||
Dbname: ppanel # MySQL数据库名 (与脚本创建的数据库一致)
|
||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||
MaxIdleConns: 10
|
||||
|
||||
@ -14,6 +14,7 @@ import (
|
||||
"github.com/hibiken/asynq"
|
||||
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
|
||||
ordermodel "github.com/perfect-panel/server/internal/model/order"
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
@ -61,6 +62,21 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
||||
var existTx *iapmodel.Transaction
|
||||
existTx, _ = iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txPayload.OriginalTransactionId)
|
||||
l.Infow("幂等等检查", logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("exists", existTx != nil && existTx.Id > 0))
|
||||
var orderInfo *ordermodel.Order
|
||||
if req.OrderNo != "" {
|
||||
ord, orderErr := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
|
||||
switch {
|
||||
case orderErr == nil && ord != nil && ord.Id > 0:
|
||||
if ord.UserId != 0 && ord.UserId != u.Id {
|
||||
l.Errorw("订单与当前用户不匹配", logger.Field("orderNo", req.OrderNo), logger.Field("orderUserId", ord.UserId), logger.Field("userId", u.Id))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "order owner mismatch")
|
||||
}
|
||||
orderInfo = ord
|
||||
case orderErr != nil && !errors.Is(orderErr, gorm.ErrRecordNotFound):
|
||||
l.Errorw("查询订单失败", logger.Field("orderNo", req.OrderNo), logger.Field("error", orderErr.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find order error: %v", orderErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// 解析 Apple 商品ID中的单位与数量:支持 dayN / monthN / yearN
|
||||
var parsedUnit string
|
||||
@ -152,14 +168,12 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
||||
}
|
||||
if subscribeId == 0 {
|
||||
// fallback from order_no if provided
|
||||
if req.OrderNo != "" {
|
||||
if ord, e := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo); e == nil && ord != nil && ord.Id != 0 {
|
||||
duration = ord.Quantity
|
||||
subscribeId = ord.SubscribeId
|
||||
l.Infow("使用订单信息回退", logger.Field("orderNo", req.OrderNo), logger.Field("durationDays", duration), logger.Field("subscribeId", subscribeId))
|
||||
} else {
|
||||
l.Infow("订单信息不可用,尝试请求参数回退", logger.Field("orderNo", req.OrderNo))
|
||||
}
|
||||
if orderInfo != nil && orderInfo.Id > 0 {
|
||||
duration = orderInfo.Quantity
|
||||
subscribeId = orderInfo.SubscribeId
|
||||
l.Infow("使用订单信息回退", logger.Field("orderNo", req.OrderNo), logger.Field("durationDays", duration), logger.Field("subscribeId", subscribeId))
|
||||
} else if req.OrderNo != "" {
|
||||
l.Infow("订单信息不可用,尝试请求参数回退", logger.Field("orderNo", req.OrderNo))
|
||||
}
|
||||
// final fallback: use request fields
|
||||
if duration <= 0 {
|
||||
@ -179,25 +193,49 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
||||
}
|
||||
exp := iapapple.CalcExpire(txPayload.PurchaseDate, duration)
|
||||
l.Infow("计算订阅到期时间", logger.Field("expireAt", exp), logger.Field("expireUnix", exp.Unix()))
|
||||
var orderLinkedSub *user.Subscribe
|
||||
if orderInfo != nil && orderInfo.SubscribeToken != "" {
|
||||
orderSub, subErr := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, orderInfo.SubscribeToken)
|
||||
switch {
|
||||
case subErr == nil && orderSub != nil && orderSub.Id > 0:
|
||||
if orderSub.UserId != u.Id {
|
||||
l.Errorw("订单订阅与当前用户不匹配", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("orderSubUserId", orderSub.UserId), logger.Field("userId", u.Id))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "order subscribe owner mismatch")
|
||||
}
|
||||
orderLinkedSub = orderSub
|
||||
subscribeId = orderSub.SubscribeId
|
||||
l.Infow("IAP 绑定命中订单订阅", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("userSubscribeId", orderSub.Id), logger.Field("subscribeToken", orderInfo.SubscribeToken))
|
||||
case subErr != nil && !errors.Is(subErr, gorm.ErrRecordNotFound):
|
||||
l.Errorw("查询订单订阅失败", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("subscribeToken", orderInfo.SubscribeToken), logger.Field("error", subErr.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find order subscribe error: %v", subErr.Error())
|
||||
}
|
||||
}
|
||||
var singleModeAnchorSub *user.Subscribe
|
||||
if l.svcCtx.Config.Subscribe.SingleModel && orderLinkedSub == nil {
|
||||
anchorSub, anchorErr := findSingleModeMergeTarget(l.ctx, l.svcCtx, u.Id, subscribeId)
|
||||
switch {
|
||||
case errors.Is(anchorErr, commonLogic.ErrSingleModePlanMismatch):
|
||||
l.Errorw("单订阅模式下 IAP 套餐不匹配", logger.Field("userId", u.Id), logger.Field("orderNo", req.OrderNo), logger.Field("iapSubscribeId", subscribeId))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch")
|
||||
case anchorErr == nil && anchorSub != nil && anchorSub.Id > 0:
|
||||
singleModeAnchorSub = anchorSub
|
||||
subscribeId = anchorSub.SubscribeId
|
||||
l.Infow("IAP 绑定命中单订阅锚点", logger.Field("userSubscribeId", anchorSub.Id), logger.Field("subscribeId", anchorSub.SubscribeId))
|
||||
case errors.Is(anchorErr, gorm.ErrRecordNotFound):
|
||||
case anchorErr != nil:
|
||||
l.Errorw("查询单订阅锚点失败", logger.Field("userId", u.Id), logger.Field("error", anchorErr.Error()))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find single mode anchor subscribe error: %v", anchorErr.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if existTx != nil && existTx.Id > 0 {
|
||||
token := fmt.Sprintf("iap:%s", txPayload.OriginalTransactionId)
|
||||
existSub, err := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, token)
|
||||
existSub, err := l.findIAPSubscribeByOriginalTransactionID(txPayload.OriginalTransactionId)
|
||||
switch {
|
||||
case err == nil && existSub != nil && existSub.Id > 0:
|
||||
newExpire := existSub.ExpireTime
|
||||
if exp.After(newExpire) {
|
||||
existSub.ExpireTime = exp
|
||||
newExpire = exp
|
||||
}
|
||||
if subscribeId > 0 && existSub.SubscribeId != subscribeId {
|
||||
existSub.SubscribeId = subscribeId
|
||||
}
|
||||
existSub.Status = 1
|
||||
existSub.FinishedAt = nil
|
||||
if err := l.svcCtx.UserModel.UpdateSubscribe(l.ctx, existSub); err != nil {
|
||||
l.Errorw("刷新 IAP 订阅失败", logger.Field("error", err.Error()), logger.Field("subscribeId", existSub.Id))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update iap subscribe failed: %v", err.Error())
|
||||
newExpire, updateErr := l.extendSubscribeForIAP(existSub, exp, subscribeId)
|
||||
if updateErr != nil {
|
||||
l.Errorw("刷新 IAP 订阅失败", logger.Field("error", updateErr.Error()), logger.Field("subscribeId", existSub.Id))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update iap subscribe failed: %v", updateErr.Error())
|
||||
}
|
||||
l.Infow("事务已处理,刷新订阅到期时间", logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("tier", tier), logger.Field("expiresAt", newExpire.Unix()))
|
||||
return &types.AttachAppleTransactionResponse{
|
||||
@ -205,9 +243,27 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
||||
Tier: tier,
|
||||
}, nil
|
||||
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound):
|
||||
l.Errorw("查询 IAP 订阅失败", logger.Field("error", err.Error()), logger.Field("token", token))
|
||||
l.Errorw("查询 IAP 订阅失败", logger.Field("error", err.Error()), logger.Field("originalTransactionId", txPayload.OriginalTransactionId))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find iap subscribe error: %v", err.Error())
|
||||
}
|
||||
if orderLinkedSub != nil {
|
||||
newExpire, updateErr := l.extendSubscribeForIAP(orderLinkedSub, exp, subscribeId)
|
||||
if updateErr != nil {
|
||||
l.Errorw("刷新订单关联订阅失败", logger.Field("error", updateErr.Error()), logger.Field("subscribeId", orderLinkedSub.Id))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update order subscribe failed: %v", updateErr.Error())
|
||||
}
|
||||
l.Infow("事务已处理,刷新订单关联订阅到期时间", logger.Field("orderNo", req.OrderNo), logger.Field("userSubscribeId", orderLinkedSub.Id), logger.Field("expiresAt", newExpire.Unix()))
|
||||
return &types.AttachAppleTransactionResponse{ExpiresAt: newExpire.Unix(), Tier: tier}, nil
|
||||
}
|
||||
if singleModeAnchorSub != nil {
|
||||
newExpire, updateErr := l.extendSubscribeForIAP(singleModeAnchorSub, exp, subscribeId)
|
||||
if updateErr != nil {
|
||||
l.Errorw("刷新单订阅锚点订阅失败", logger.Field("error", updateErr.Error()), logger.Field("subscribeId", singleModeAnchorSub.Id))
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update single mode anchor subscribe failed: %v", updateErr.Error())
|
||||
}
|
||||
l.Infow("事务已处理,刷新单订阅锚点到期时间", logger.Field("userSubscribeId", singleModeAnchorSub.Id), logger.Field("expiresAt", newExpire.Unix()))
|
||||
return &types.AttachAppleTransactionResponse{ExpiresAt: newExpire.Unix(), Tier: tier}, nil
|
||||
}
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(req.SignedTransactionJWS))
|
||||
@ -230,28 +286,47 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
||||
}
|
||||
l.Infow("写入事务表成功", logger.Field("id", iapTx.Id))
|
||||
}
|
||||
// insert user_subscribe
|
||||
userSub := user.Subscribe{
|
||||
UserId: u.Id,
|
||||
SubscribeId: subscribeId,
|
||||
StartTime: time.Now(),
|
||||
ExpireTime: exp,
|
||||
Traffic: 0,
|
||||
Download: 0,
|
||||
Upload: 0,
|
||||
Token: fmt.Sprintf("iap:%s", txPayload.OriginalTransactionId),
|
||||
UUID: uuid.New().String(),
|
||||
Status: 1,
|
||||
merged := false
|
||||
if orderLinkedSub != nil {
|
||||
if _, e := l.extendSubscribeForIAP(orderLinkedSub, exp, subscribeId, tx); e != nil {
|
||||
l.Errorw("更新订单关联订阅失败", logger.Field("error", e.Error()), logger.Field("userSubscribeId", orderLinkedSub.Id))
|
||||
return e
|
||||
}
|
||||
merged = true
|
||||
} else if singleModeAnchorSub != nil {
|
||||
if _, e := l.extendSubscribeForIAP(singleModeAnchorSub, exp, subscribeId, tx); e != nil {
|
||||
l.Errorw("更新单订阅锚点失败", logger.Field("error", e.Error()), logger.Field("userSubscribeId", singleModeAnchorSub.Id))
|
||||
return e
|
||||
}
|
||||
merged = true
|
||||
}
|
||||
if e := l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx); e != nil {
|
||||
l.Errorw("写入用户订阅失败", logger.Field("error", e.Error()))
|
||||
return e
|
||||
if !merged {
|
||||
userSub := user.Subscribe{
|
||||
UserId: u.Id,
|
||||
SubscribeId: subscribeId,
|
||||
StartTime: time.Now(),
|
||||
ExpireTime: exp,
|
||||
Traffic: 0,
|
||||
Download: 0,
|
||||
Upload: 0,
|
||||
Token: fmt.Sprintf("iap:%s", txPayload.OriginalTransactionId),
|
||||
UUID: uuid.New().String(),
|
||||
Status: 1,
|
||||
}
|
||||
if e := l.svcCtx.UserModel.InsertSubscribe(l.ctx, &userSub, tx); e != nil {
|
||||
l.Errorw("写入用户订阅失败", logger.Field("error", e.Error()))
|
||||
return e
|
||||
}
|
||||
l.Infow("写入用户订阅成功", logger.Field("userId", u.Id), logger.Field("subscribeId", subscribeId), logger.Field("expireUnix", exp.Unix()))
|
||||
}
|
||||
l.Infow("写入用户订阅成功", logger.Field("userId", u.Id), logger.Field("subscribeId", subscribeId), logger.Field("expireUnix", exp.Unix()))
|
||||
// optional: mark related order as paid and enqueue activation
|
||||
if req.OrderNo != "" {
|
||||
orderInfo, e := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo)
|
||||
if e != nil {
|
||||
if orderInfo == nil {
|
||||
if ord, e := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OrderNo); e == nil && ord != nil && ord.Id > 0 {
|
||||
orderInfo = ord
|
||||
}
|
||||
}
|
||||
if orderInfo == nil {
|
||||
// do not fail transaction if order not found; just continue
|
||||
l.Infow("订单不存在或查询失败,跳过订单状态更新", logger.Field("orderNo", req.OrderNo))
|
||||
return nil
|
||||
@ -286,3 +361,40 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
||||
Tier: tier,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (l *AttachTransactionLogic) findIAPSubscribeByOriginalTransactionID(originalTransactionID string) (*user.Subscribe, error) {
|
||||
if originalTransactionID == "" {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
candidates := []string{fmt.Sprintf("iap:%s", originalTransactionID), originalTransactionID}
|
||||
for _, token := range candidates {
|
||||
sub, err := l.svcCtx.UserModel.FindOneSubscribeByToken(l.ctx, token)
|
||||
if err == nil && sub != nil && sub.Id > 0 {
|
||||
return sub, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
func (l *AttachTransactionLogic) extendSubscribeForIAP(userSub *user.Subscribe, exp time.Time, subscribeId int64, tx ...*gorm.DB) (time.Time, error) {
|
||||
if userSub == nil {
|
||||
return time.Time{}, errors.New("user subscribe is nil")
|
||||
}
|
||||
newExpire := userSub.ExpireTime
|
||||
if exp.After(newExpire) {
|
||||
newExpire = exp
|
||||
}
|
||||
userSub.ExpireTime = newExpire
|
||||
if subscribeId > 0 && userSub.SubscribeId != subscribeId {
|
||||
userSub.SubscribeId = subscribeId
|
||||
}
|
||||
userSub.Status = 1
|
||||
userSub.FinishedAt = nil
|
||||
if err := l.svcCtx.UserModel.UpdateSubscribe(l.ctx, userSub, tx...); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return newExpire, nil
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package apple
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -153,6 +154,26 @@ SIRDAVLcWemp0fMlnfDE4EHmqcD58arEJWsr3aWEhc4BHocOUIGjko0cVWGchrFa
|
||||
// But for now, we only rely on appAccountToken or just skip order linking.
|
||||
|
||||
exp := iapapple.CalcExpire(txp.PurchaseDate, m.DurationDays)
|
||||
if l.svcCtx.Config.Subscribe.SingleModel {
|
||||
anchorSub, anchorErr := findSingleModeMergeTarget(l.ctx, l.svcCtx, u.Id, m.SubscribeId)
|
||||
switch {
|
||||
case errors.Is(anchorErr, commonLogic.ErrSingleModePlanMismatch):
|
||||
return errors.Wrapf(xerr.NewErrCode(xerr.SingleSubscribePlanMismatch), "single subscribe mode plan mismatch")
|
||||
case anchorErr == nil && anchorSub != nil && anchorSub.Id > 0:
|
||||
if exp.After(anchorSub.ExpireTime) {
|
||||
anchorSub.ExpireTime = exp
|
||||
}
|
||||
anchorSub.Status = 1
|
||||
anchorSub.FinishedAt = nil
|
||||
if err := l.svcCtx.UserModel.UpdateSubscribe(l.ctx, anchorSub, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
case errors.Is(anchorErr, gorm.ErrRecordNotFound):
|
||||
case anchorErr != nil:
|
||||
return anchorErr
|
||||
}
|
||||
}
|
||||
userSub := user.Subscribe{
|
||||
UserId: u.Id,
|
||||
SubscribeId: m.SubscribeId,
|
||||
@ -161,7 +182,7 @@ SIRDAVLcWemp0fMlnfDE4EHmqcD58arEJWsr3aWEhc4BHocOUIGjko0cVWGchrFa
|
||||
Traffic: 0,
|
||||
Download: 0,
|
||||
Upload: 0,
|
||||
Token: txp.OriginalTransactionId,
|
||||
Token: fmt.Sprintf("iap:%s", txp.OriginalTransactionId),
|
||||
UUID: uuid.New().String(),
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
65
internal/logic/public/iap/apple/singleModeHelper.go
Normal file
65
internal/logic/public/iap/apple/singleModeHelper.go
Normal file
@ -0,0 +1,65 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func findSingleModeMergeTarget(ctx context.Context, svcCtx *svc.ServiceContext, userId int64, subscribeId int64) (*user.Subscribe, error) {
|
||||
anchorSub, err := svcCtx.UserModel.FindSingleModeAnchorSubscribe(ctx, userId)
|
||||
switch {
|
||||
case err == nil && anchorSub != nil && anchorSub.Id > 0:
|
||||
if subscribeId > 0 && anchorSub.SubscribeId != subscribeId {
|
||||
return nil, commonLogic.ErrSingleModePlanMismatch
|
||||
}
|
||||
return anchorSub, nil
|
||||
case err != nil && !errors.Is(err, gorm.ErrRecordNotFound):
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userSubs, queryErr := svcCtx.UserModel.QueryUserSubscribe(ctx, userId, 0, 1, 2, 3, 5)
|
||||
if queryErr != nil {
|
||||
return nil, queryErr
|
||||
}
|
||||
var candidate *user.SubscribeDetails
|
||||
for _, item := range userSubs {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
if subscribeId > 0 && item.SubscribeId != subscribeId {
|
||||
continue
|
||||
}
|
||||
if candidate == nil ||
|
||||
item.ExpireTime.After(candidate.ExpireTime) ||
|
||||
(item.ExpireTime.Equal(candidate.ExpireTime) && item.UpdatedAt.After(candidate.UpdatedAt)) ||
|
||||
(item.ExpireTime.Equal(candidate.ExpireTime) && item.UpdatedAt.Equal(candidate.UpdatedAt) && item.Id > candidate.Id) {
|
||||
candidate = item
|
||||
}
|
||||
}
|
||||
if candidate == nil {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return &user.Subscribe{
|
||||
Id: candidate.Id,
|
||||
UserId: candidate.UserId,
|
||||
OrderId: candidate.OrderId,
|
||||
SubscribeId: candidate.SubscribeId,
|
||||
StartTime: candidate.StartTime,
|
||||
ExpireTime: candidate.ExpireTime,
|
||||
FinishedAt: candidate.FinishedAt,
|
||||
Traffic: candidate.Traffic,
|
||||
Download: candidate.Download,
|
||||
Upload: candidate.Upload,
|
||||
Token: candidate.Token,
|
||||
UUID: candidate.UUID,
|
||||
Status: candidate.Status,
|
||||
Note: candidate.Note,
|
||||
CreatedAt: candidate.CreatedAt,
|
||||
UpdatedAt: candidate.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user