feat: sync public links and phone change updates
This commit is contained in:
parent
41c7f1d018
commit
da04c0fa6a
53 changed files with 3029 additions and 111 deletions
185
internal/app/account/phone_change.go
Normal file
185
internal/app/account/phone_change.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
type reliablePhoneChangeDispatcher interface {
|
||||
UsesReliableDispatch() bool
|
||||
}
|
||||
|
||||
func (s *Service) PhoneChangeUsesReliableDispatch() bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
reporter, ok := s.phoneChanges.(reliablePhoneChangeDispatcher)
|
||||
return ok && reporter.UsesReliableDispatch()
|
||||
}
|
||||
|
||||
// SendChangePhoneCode 创建只允许当前 user + perm auth_key 消费的改号验证码。
|
||||
// CodeStore 会按 purpose+user+auth_key+phone 原子轮换:同一作用域的新请求
|
||||
// 立即使旧 hash 失效,避免 Android 返回重进页面时留下并行有效验证码。
|
||||
// SessionID 被记录用于审计,但验证时不要求相等:同一设备在等待短信期间发生
|
||||
// MTProto session 重建仍可完成流程;其它设备因 auth_key 不同无法复用。
|
||||
func (s *Service) SendChangePhoneCode(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone string) (string, domain.AuthCodeDelivery, error) {
|
||||
phone = domain.NormalizePhone(phone)
|
||||
if !domain.ValidPhone(phone) {
|
||||
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberInvalid
|
||||
}
|
||||
if _, err := s.phoneChangeCaller(ctx, userID, authKeyID); err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
if existing, found, err := s.users.ByPhone(ctx, phone); err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
} else if found && existing.ID != 0 {
|
||||
return "", domain.AuthCodeDelivery{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
if s.codes == nil || strings.TrimSpace(s.phoneChangeCode) == "" {
|
||||
return "", domain.AuthCodeDelivery{}, fmt.Errorf("phone change code service is not configured")
|
||||
}
|
||||
hash, err := phoneChangeHash()
|
||||
if err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, err
|
||||
}
|
||||
rec := store.PhoneCode{
|
||||
Phone: phone,
|
||||
Code: s.phoneChangeCode,
|
||||
Channel: "phone",
|
||||
Purpose: store.PhoneCodePurposeChangePhone,
|
||||
UserID: userID,
|
||||
AuthKeyID: authKeyID,
|
||||
SessionID: sessionID,
|
||||
MaxAttempts: s.phoneChangeMaxAttempts,
|
||||
}
|
||||
if err := s.codes.Set(ctx, hash, rec, s.phoneChangeCodeTTL); err != nil {
|
||||
return "", domain.AuthCodeDelivery{}, fmt.Errorf("store phone change code: %w", err)
|
||||
}
|
||||
return hash, domain.AuthCodeDelivery{Kind: domain.AuthCodeDeliverySMS, Length: len(rec.Code)}, nil
|
||||
}
|
||||
|
||||
// ChangePhone 验证作用域和验证码后执行原子改号。返回事件用于当前 session 的
|
||||
// pts 簿记;其它 session 由 transactional outbox 投递 updateUserPhone。
|
||||
func (s *Service) ChangePhone(ctx context.Context, userID int64, authKeyID [8]byte, sessionID int64, phone, phoneCodeHash, code string, date int) (domain.PhoneChangeResult, error) {
|
||||
if strings.TrimSpace(phoneCodeHash) == "" || strings.TrimSpace(code) == "" {
|
||||
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeEmpty
|
||||
}
|
||||
phone = domain.NormalizePhone(phone)
|
||||
if !domain.ValidPhone(phone) {
|
||||
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberInvalid
|
||||
}
|
||||
if _, err := s.phoneChangeCaller(ctx, userID, authKeyID); err != nil {
|
||||
return domain.PhoneChangeResult{}, err
|
||||
}
|
||||
if s.codes == nil || s.phoneChanges == nil {
|
||||
return domain.PhoneChangeResult{}, fmt.Errorf("phone change service is not configured")
|
||||
}
|
||||
rec, found, err := s.codes.Get(ctx, phoneCodeHash)
|
||||
if err != nil {
|
||||
return domain.PhoneChangeResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeExpired
|
||||
}
|
||||
if rec.Purpose != store.PhoneCodePurposeChangePhone || rec.Phone != phone || rec.UserID != userID || rec.AuthKeyID != authKeyID {
|
||||
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
code = strings.TrimSpace(code)
|
||||
if subtle.ConstantTimeCompare([]byte(rec.Code), []byte(code)) != 1 {
|
||||
return domain.PhoneChangeResult{}, s.rejectPhoneChangeCode(ctx, phoneCodeHash, rec)
|
||||
}
|
||||
if existing, occupied, err := s.users.ByPhone(ctx, phone); err != nil {
|
||||
return domain.PhoneChangeResult{}, err
|
||||
} else if occupied && existing.ID != userID {
|
||||
return domain.PhoneChangeResult{}, domain.ErrPhoneNumberOccupied
|
||||
}
|
||||
// 正确 code 必须在进入持久化事务前原子消费。并发重放中只有一个请求能
|
||||
// 获得记录,其余请求不得再次推进 pts 或追加 user_phone event。
|
||||
consumed, found, err := s.codes.ConsumeScoped(ctx, phoneCodeHash, store.PhoneCodeScope{
|
||||
Purpose: store.PhoneCodePurposeChangePhone,
|
||||
UserID: userID,
|
||||
AuthKeyID: authKeyID,
|
||||
Phone: phone,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.PhoneChangeResult{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeExpired
|
||||
}
|
||||
if consumed.Purpose != rec.Purpose || consumed.UserID != rec.UserID || consumed.AuthKeyID != rec.AuthKeyID || consumed.Phone != rec.Phone ||
|
||||
subtle.ConstantTimeCompare([]byte(consumed.Code), []byte(code)) != 1 {
|
||||
return domain.PhoneChangeResult{}, domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
if date == 0 {
|
||||
date = int(time.Now().Unix())
|
||||
}
|
||||
result, err := s.phoneChanges.ChangePhone(ctx, domain.PhoneChangeRequest{
|
||||
UserID: userID,
|
||||
Phone: phone,
|
||||
Date: date,
|
||||
ExcludeAuthKeyID: authKeyID,
|
||||
ExcludeSessionID: sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.PhoneChangeResult{}, err
|
||||
}
|
||||
if s.userCache != nil && result.User.ID != 0 {
|
||||
_ = s.userCache.Delete(ctx, []int64{result.User.ID})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) phoneChangeCaller(ctx context.Context, userID int64, authKeyID [8]byte) (domain.User, error) {
|
||||
if s == nil || s.users == nil || s.authorizations == nil || userID == 0 || authKeyID == ([8]byte{}) {
|
||||
return domain.User{}, domain.ErrPhoneChangeAuthInvalid
|
||||
}
|
||||
a, found, err := s.authorizations.ByAuthKey(ctx, authKeyID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found || a.UserID != userID || a.PasswordPending {
|
||||
return domain.User{}, domain.ErrPhoneChangeAuthInvalid
|
||||
}
|
||||
u, found, err := s.users.ByID(ctx, userID)
|
||||
if err != nil {
|
||||
return domain.User{}, err
|
||||
}
|
||||
if !found {
|
||||
return domain.User{}, domain.ErrPhoneChangeAuthInvalid
|
||||
}
|
||||
if u.Bot || domain.IsSystemUserID(u.ID) {
|
||||
return domain.User{}, domain.ErrPhoneChangeForbidden
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Service) rejectPhoneChangeCode(ctx context.Context, hash string, rec store.PhoneCode) error {
|
||||
rec.Attempts++
|
||||
max := rec.MaxAttempts
|
||||
if max <= 0 {
|
||||
max = s.phoneChangeMaxAttempts
|
||||
}
|
||||
if max > 0 && rec.Attempts >= max {
|
||||
_ = s.codes.Del(ctx, hash)
|
||||
return domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
_ = s.codes.Update(ctx, hash, rec)
|
||||
return domain.ErrPhoneCodeInvalid
|
||||
}
|
||||
|
||||
func phoneChangeHash() (string, error) {
|
||||
var raw [8]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", fmt.Errorf("generate phone change hash: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(raw[:]), nil
|
||||
}
|
||||
196
internal/app/account/phone_change_test.go
Normal file
196
internal/app/account/phone_change_test.go
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
type phoneChangeFixture struct {
|
||||
ctx context.Context
|
||||
service *Service
|
||||
users *memory.UserStore
|
||||
auths *memory.AuthorizationStore
|
||||
codes *memory.CodeStore
|
||||
events *memory.UpdateEventStore
|
||||
user domain.User
|
||||
authKeyID [8]byte
|
||||
}
|
||||
|
||||
func newPhoneChangeFixture(t *testing.T) phoneChangeFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
users := memory.NewUserStore()
|
||||
auths := memory.NewAuthorizationStore()
|
||||
codes := memory.NewCodeStore()
|
||||
events := memory.NewUpdateEventStore()
|
||||
u, err := users.Create(ctx, domain.User{AccessHash: 101, Phone: "15550012001", FirstName: "Alice"})
|
||||
if err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
authKeyID := [8]byte{1, 2, 3, 4}
|
||||
if err := auths.Bind(ctx, domain.Authorization{AuthKeyID: authKeyID, UserID: u.ID, CreatedAt: time.Now().Add(-48 * time.Hour)}); err != nil {
|
||||
t.Fatalf("bind auth: %v", err)
|
||||
}
|
||||
service := NewService(
|
||||
memory.NewPasswordStore(),
|
||||
WithUsers(users),
|
||||
WithPhoneChange(memory.NewPhoneChangeStore(users, events), auths, codes, nil, "12345", time.Minute, 3),
|
||||
)
|
||||
return phoneChangeFixture{ctx: ctx, service: service, users: users, auths: auths, codes: codes, events: events, user: u, authKeyID: authKeyID}
|
||||
}
|
||||
|
||||
func TestPhoneChangeScopesCodeAndPersistsDurableEvent(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
hash, delivery, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "+1 (555) 001-2002")
|
||||
if err != nil {
|
||||
t.Fatalf("send change code: %v", err)
|
||||
}
|
||||
if hash == "" || delivery.Kind != domain.AuthCodeDeliverySMS || delivery.Length != 5 {
|
||||
t.Fatalf("delivery = hash %q %+v", hash, delivery)
|
||||
}
|
||||
rec, found, err := f.codes.Get(f.ctx, hash)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load code found=%v err=%v", found, err)
|
||||
}
|
||||
if rec.Purpose != store.PhoneCodePurposeChangePhone || rec.Phone != "15550012002" || rec.UserID != f.user.ID || rec.AuthKeyID != f.authKeyID || rec.SessionID != 77 {
|
||||
t.Fatalf("scoped code = %+v", rec)
|
||||
}
|
||||
|
||||
result, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 88, "+1 555 001 2002", hash, "12345", 1700000000)
|
||||
if err != nil {
|
||||
t.Fatalf("change phone after session reconnect: %v", err)
|
||||
}
|
||||
if !result.Changed || result.User.Phone != "15550012002" || result.Event.Type != domain.UpdateEventUserPhone || result.Event.Phone != "15550012002" || result.Event.Pts != 1 {
|
||||
t.Fatalf("change result = %+v", result)
|
||||
}
|
||||
if _, found, _ := f.users.ByPhone(f.ctx, "15550012001"); found {
|
||||
t.Fatal("old phone still resolves")
|
||||
}
|
||||
if got, found, _ := f.users.ByPhone(f.ctx, "15550012002"); !found || got.ID != f.user.ID {
|
||||
t.Fatalf("new phone resolves to %+v found=%v", got, found)
|
||||
}
|
||||
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
|
||||
if err != nil || len(events) != 1 || events[0].Type != domain.UpdateEventUserPhone || events[0].Phone != "15550012002" {
|
||||
t.Fatalf("durable events = %+v err=%v", events, err)
|
||||
}
|
||||
if _, found, _ := f.codes.Get(f.ctx, hash); found {
|
||||
t.Fatal("successful code was not consumed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneChangeRejectsOccupiedAndCrossAuthCode(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
occupied, err := f.users.Create(f.ctx, domain.User{AccessHash: 102, Phone: "15550012003", FirstName: "Bob"})
|
||||
if err != nil {
|
||||
t.Fatalf("create occupied user: %v", err)
|
||||
}
|
||||
if _, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, occupied.Phone); !errors.Is(err, domain.ErrPhoneNumberOccupied) {
|
||||
t.Fatalf("occupied send err = %v", err)
|
||||
}
|
||||
|
||||
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012004")
|
||||
if err != nil {
|
||||
t.Fatalf("send code: %v", err)
|
||||
}
|
||||
otherKey := [8]byte{9, 9, 9}
|
||||
if err := f.auths.Bind(f.ctx, domain.Authorization{AuthKeyID: otherKey, UserID: occupied.ID}); err != nil {
|
||||
t.Fatalf("bind other auth: %v", err)
|
||||
}
|
||||
if _, err := f.service.ChangePhone(f.ctx, occupied.ID, otherKey, 99, "15550012004", hash, "12345", 0); !errors.Is(err, domain.ErrPhoneCodeInvalid) {
|
||||
t.Fatalf("cross-auth change err = %v", err)
|
||||
}
|
||||
if got, found, _ := f.users.ByID(f.ctx, occupied.ID); !found || got.Phone != "15550012003" {
|
||||
t.Fatalf("other user changed = %+v found=%v", got, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneChangeWrongCodeExhaustsAttempts(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005")
|
||||
if err != nil {
|
||||
t.Fatalf("send code: %v", err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005", hash, "00000", 0); !errors.Is(err, domain.ErrPhoneCodeInvalid) {
|
||||
t.Fatalf("wrong attempt %d err = %v", i+1, err)
|
||||
}
|
||||
}
|
||||
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 77, "15550012005", hash, "12345", 0); !errors.Is(err, domain.ErrPhoneCodeExpired) {
|
||||
t.Fatalf("exhausted code err = %v", err)
|
||||
}
|
||||
if got, _, _ := f.users.ByID(f.ctx, f.user.ID); got.Phone != "15550012001" {
|
||||
t.Fatalf("phone changed after exhausted code: %q", got.Phone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneChangeNewSendInvalidatesPreviousHash(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
oldHash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012006")
|
||||
if err != nil {
|
||||
t.Fatalf("first send: %v", err)
|
||||
}
|
||||
newHash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 88, "15550012006")
|
||||
if err != nil {
|
||||
t.Fatalf("second send: %v", err)
|
||||
}
|
||||
if oldHash == newHash {
|
||||
t.Fatalf("hash was not rotated: %q", oldHash)
|
||||
}
|
||||
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 99, "15550012006", oldHash, "12345", 1700000001); !errors.Is(err, domain.ErrPhoneCodeExpired) {
|
||||
t.Fatalf("old hash replay err = %v", err)
|
||||
}
|
||||
if _, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 99, "15550012006", newHash, "12345", 1700000002); err != nil {
|
||||
t.Fatalf("new hash change: %v", err)
|
||||
}
|
||||
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
|
||||
if err != nil || len(events) != 1 || events[0].Type != domain.UpdateEventUserPhone {
|
||||
t.Fatalf("events = %+v err=%v", events, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneChangeConcurrentReplayAppendsOneEvent(t *testing.T) {
|
||||
f := newPhoneChangeFixture(t)
|
||||
hash, _, err := f.service.SendChangePhoneCode(f.ctx, f.user.ID, f.authKeyID, 77, "15550012007")
|
||||
if err != nil {
|
||||
t.Fatalf("send: %v", err)
|
||||
}
|
||||
const workers = 24
|
||||
errs := make(chan error, workers)
|
||||
var wg sync.WaitGroup
|
||||
for range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := f.service.ChangePhone(f.ctx, f.user.ID, f.authKeyID, 88, "15550012007", hash, "12345", 1700000003)
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
successes := 0
|
||||
expired := 0
|
||||
for err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
successes++
|
||||
case errors.Is(err, domain.ErrPhoneCodeExpired):
|
||||
expired++
|
||||
default:
|
||||
t.Fatalf("unexpected concurrent error: %v", err)
|
||||
}
|
||||
}
|
||||
if successes != 1 || expired != workers-1 {
|
||||
t.Fatalf("successes=%d expired=%d", successes, expired)
|
||||
}
|
||||
events, err := f.events.ListAfter(f.ctx, f.user.ID, 0, 10)
|
||||
if err != nil || len(events) != 1 || events[0].Pts != 1 {
|
||||
t.Fatalf("events = %+v err=%v", events, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -38,8 +38,14 @@ type Service struct {
|
|||
business store.BusinessAutomationStore
|
||||
// users 仅用于登录邮箱的 phone→user 解析(sendCode 检测 / login-setup / reset 走 phone)。
|
||||
users store.UserStore
|
||||
userCache store.UserCache
|
||||
authorizations store.AuthorizationStore
|
||||
phoneChanges store.PhoneChangeStore
|
||||
publicBaseURL string
|
||||
codes store.CodeStore
|
||||
phoneChangeCode string
|
||||
phoneChangeCodeTTL time.Duration
|
||||
phoneChangeMaxAttempts int
|
||||
loginEmailSender mail.Sender
|
||||
loginEmailCodeTTL time.Duration
|
||||
loginEmailCodeMaxAttempts int
|
||||
|
|
@ -105,6 +111,24 @@ func WithUsers(users store.UserStore) ServiceOption {
|
|||
}
|
||||
}
|
||||
|
||||
// WithPhoneChange 注入改号所需的授权校验、一次性验证码、原子 user+update
|
||||
// 写入与基础用户缓存失效依赖。
|
||||
func WithPhoneChange(phoneChanges store.PhoneChangeStore, authorizations store.AuthorizationStore, codes store.CodeStore, cache store.UserCache, fixedCode string, ttl time.Duration, maxAttempts int) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.phoneChanges = phoneChanges
|
||||
s.authorizations = authorizations
|
||||
s.codes = codes
|
||||
s.userCache = cache
|
||||
s.phoneChangeCode = fixedCode
|
||||
if ttl > 0 {
|
||||
s.phoneChangeCodeTTL = ttl
|
||||
}
|
||||
if maxAttempts > 0 {
|
||||
s.phoneChangeMaxAttempts = maxAttempts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithPublicBaseURL(baseURL string) ServiceOption {
|
||||
return func(s *Service) {
|
||||
s.publicBaseURL = links.NormalizeBaseURL(baseURL)
|
||||
|
|
@ -129,7 +153,15 @@ func WithLoginEmailVerification(codes store.CodeStore, sender mail.Sender, ttl t
|
|||
|
||||
// NewService 创建 account 服务。
|
||||
func NewService(passwords store.PasswordStore, opts ...ServiceOption) *Service {
|
||||
s := &Service{passwords: passwords, publicBaseURL: links.DefaultPublicBaseURL, loginEmailCodeTTL: 5 * time.Minute, loginEmailCodeMaxAttempts: 5, loginEmailCodeLength: 6}
|
||||
s := &Service{
|
||||
passwords: passwords,
|
||||
publicBaseURL: links.DefaultPublicBaseURL,
|
||||
loginEmailCodeTTL: 5 * time.Minute,
|
||||
loginEmailCodeMaxAttempts: 5,
|
||||
loginEmailCodeLength: 6,
|
||||
phoneChangeCodeTTL: 5 * time.Minute,
|
||||
phoneChangeMaxAttempts: 5,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(s)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue