用户靓号收回

This commit is contained in:
zhx 2026-06-30 16:01:08 +08:00
parent a186f2b0d4
commit 6a783ec487
14 changed files with 1092 additions and 694 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1278,6 +1278,13 @@ message SetPrettyDisplayIDStatusRequest {
string reason = 5;
}
message RecyclePrettyDisplayIDRequest {
RequestMeta meta = 1;
string pretty_id = 2;
string reason = 3;
int64 operator_admin_id = 4;
}
message PrettyDisplayIDResponse {
PrettyDisplayID item = 1;
}
@ -1414,5 +1421,6 @@ service UserPrettyDisplayIDAdminService {
rpc GeneratePrettyDisplayIDs(GeneratePrettyDisplayIDsRequest) returns (GeneratePrettyDisplayIDsResponse);
rpc ListPrettyDisplayIDs(ListPrettyDisplayIDsRequest) returns (ListPrettyDisplayIDsResponse);
rpc SetPrettyDisplayIDStatus(SetPrettyDisplayIDStatusRequest) returns (PrettyDisplayIDResponse);
rpc RecyclePrettyDisplayID(RecyclePrettyDisplayIDRequest) returns (PrettyDisplayIDResponse);
rpc AdminGrantPrettyDisplayID(AdminGrantPrettyDisplayIDRequest) returns (AdminGrantPrettyDisplayIDResponse);
}

View File

@ -3171,6 +3171,7 @@ const (
UserPrettyDisplayIDAdminService_GeneratePrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/GeneratePrettyDisplayIDs"
UserPrettyDisplayIDAdminService_ListPrettyDisplayIDs_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/ListPrettyDisplayIDs"
UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/SetPrettyDisplayIDStatus"
UserPrettyDisplayIDAdminService_RecyclePrettyDisplayID_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/RecyclePrettyDisplayID"
UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_FullMethodName = "/hyapp.user.v1.UserPrettyDisplayIDAdminService/AdminGrantPrettyDisplayID"
)
@ -3186,6 +3187,7 @@ type UserPrettyDisplayIDAdminServiceClient interface {
GeneratePrettyDisplayIDs(ctx context.Context, in *GeneratePrettyDisplayIDsRequest, opts ...grpc.CallOption) (*GeneratePrettyDisplayIDsResponse, error)
ListPrettyDisplayIDs(ctx context.Context, in *ListPrettyDisplayIDsRequest, opts ...grpc.CallOption) (*ListPrettyDisplayIDsResponse, error)
SetPrettyDisplayIDStatus(ctx context.Context, in *SetPrettyDisplayIDStatusRequest, opts ...grpc.CallOption) (*PrettyDisplayIDResponse, error)
RecyclePrettyDisplayID(ctx context.Context, in *RecyclePrettyDisplayIDRequest, opts ...grpc.CallOption) (*PrettyDisplayIDResponse, error)
AdminGrantPrettyDisplayID(ctx context.Context, in *AdminGrantPrettyDisplayIDRequest, opts ...grpc.CallOption) (*AdminGrantPrettyDisplayIDResponse, error)
}
@ -3257,6 +3259,16 @@ func (c *userPrettyDisplayIDAdminServiceClient) SetPrettyDisplayIDStatus(ctx con
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) RecyclePrettyDisplayID(ctx context.Context, in *RecyclePrettyDisplayIDRequest, opts ...grpc.CallOption) (*PrettyDisplayIDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PrettyDisplayIDResponse)
err := c.cc.Invoke(ctx, UserPrettyDisplayIDAdminService_RecyclePrettyDisplayID_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userPrettyDisplayIDAdminServiceClient) AdminGrantPrettyDisplayID(ctx context.Context, in *AdminGrantPrettyDisplayIDRequest, opts ...grpc.CallOption) (*AdminGrantPrettyDisplayIDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminGrantPrettyDisplayIDResponse)
@ -3279,6 +3291,7 @@ type UserPrettyDisplayIDAdminServiceServer interface {
GeneratePrettyDisplayIDs(context.Context, *GeneratePrettyDisplayIDsRequest) (*GeneratePrettyDisplayIDsResponse, error)
ListPrettyDisplayIDs(context.Context, *ListPrettyDisplayIDsRequest) (*ListPrettyDisplayIDsResponse, error)
SetPrettyDisplayIDStatus(context.Context, *SetPrettyDisplayIDStatusRequest) (*PrettyDisplayIDResponse, error)
RecyclePrettyDisplayID(context.Context, *RecyclePrettyDisplayIDRequest) (*PrettyDisplayIDResponse, error)
AdminGrantPrettyDisplayID(context.Context, *AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResponse, error)
mustEmbedUnimplementedUserPrettyDisplayIDAdminServiceServer()
}
@ -3308,6 +3321,9 @@ func (UnimplementedUserPrettyDisplayIDAdminServiceServer) ListPrettyDisplayIDs(c
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) SetPrettyDisplayIDStatus(context.Context, *SetPrettyDisplayIDStatusRequest) (*PrettyDisplayIDResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method SetPrettyDisplayIDStatus not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) RecyclePrettyDisplayID(context.Context, *RecyclePrettyDisplayIDRequest) (*PrettyDisplayIDResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RecyclePrettyDisplayID not implemented")
}
func (UnimplementedUserPrettyDisplayIDAdminServiceServer) AdminGrantPrettyDisplayID(context.Context, *AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AdminGrantPrettyDisplayID not implemented")
}
@ -3441,6 +3457,24 @@ func _UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_Handler(srv inter
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_RecyclePrettyDisplayID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(RecyclePrettyDisplayIDRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserPrettyDisplayIDAdminServiceServer).RecyclePrettyDisplayID(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserPrettyDisplayIDAdminService_RecyclePrettyDisplayID_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserPrettyDisplayIDAdminServiceServer).RecyclePrettyDisplayID(ctx, req.(*RecyclePrettyDisplayIDRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminGrantPrettyDisplayIDRequest)
if err := dec(in); err != nil {
@ -3490,6 +3524,10 @@ var UserPrettyDisplayIDAdminService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetPrettyDisplayIDStatus",
Handler: _UserPrettyDisplayIDAdminService_SetPrettyDisplayIDStatus_Handler,
},
{
MethodName: "RecyclePrettyDisplayID",
Handler: _UserPrettyDisplayIDAdminService_RecyclePrettyDisplayID_Handler,
},
{
MethodName: "AdminGrantPrettyDisplayID",
Handler: _UserPrettyDisplayIDAdminService_AdminGrantPrettyDisplayID_Handler,

View File

@ -25,6 +25,7 @@ type Client interface {
GeneratePrettyDisplayIDs(ctx context.Context, req GeneratePrettyDisplayIDsRequest) (*PrettyDisplayIDGenerationBatch, error)
ListPrettyDisplayIDs(ctx context.Context, req ListPrettyDisplayIDsRequest) ([]*PrettyDisplayID, int64, error)
SetPrettyDisplayIDStatus(ctx context.Context, req SetPrettyDisplayIDStatusRequest) (*PrettyDisplayID, error)
RecyclePrettyDisplayID(ctx context.Context, req RecyclePrettyDisplayIDRequest) (*PrettyDisplayID, error)
AdminGrantPrettyDisplayID(ctx context.Context, req AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResult, error)
ListCountries(ctx context.Context, req ListCountriesRequest) ([]*Country, error)
UpdateCountry(ctx context.Context, req UpdateCountryRequest) (*Country, error)

View File

@ -113,6 +113,14 @@ type SetPrettyDisplayIDStatusRequest struct {
OperatorAdminID int64
}
type RecyclePrettyDisplayIDRequest struct {
RequestID string
Caller string
PrettyID string
Reason string
OperatorAdminID int64
}
type AdminGrantPrettyDisplayIDRequest struct {
RequestID string
Caller string
@ -238,6 +246,20 @@ func (c *GRPCClient) SetPrettyDisplayIDStatus(ctx context.Context, req SetPretty
return fromProtoPrettyDisplayID(resp.GetItem()), nil
}
func (c *GRPCClient) RecyclePrettyDisplayID(ctx context.Context, req RecyclePrettyDisplayIDRequest) (*PrettyDisplayID, error) {
// 回收必须走 user-service 的后台 RPC由 owner service 同事务恢复默认短号并释放靓号池记录。
resp, err := c.prettyClient.RecyclePrettyDisplayID(ctx, &userv1.RecyclePrettyDisplayIDRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
PrettyId: req.PrettyID,
Reason: req.Reason,
OperatorAdminId: req.OperatorAdminID,
})
if err != nil {
return nil, err
}
return fromProtoPrettyDisplayID(resp.GetItem()), nil
}
func (c *GRPCClient) AdminGrantPrettyDisplayID(ctx context.Context, req AdminGrantPrettyDisplayIDRequest) (*AdminGrantPrettyDisplayIDResult, error) {
// 后台发放返回当前用户身份快照admin-server 原样转给前端,方便页面确认最终展示号。
resp, err := c.prettyClient.AdminGrantPrettyDisplayID(ctx, &userv1.AdminGrantPrettyDisplayIDRequest{

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"io"
"math"
"strconv"
"strings"
@ -68,6 +69,10 @@ type statusRequest struct {
Reason string `json:"reason"`
}
type recycleRequest struct {
Reason string `json:"reason"`
}
func (h *Handler) ListPools(c *gin.Context) {
page, pageSize, ok := pageQuery(c)
if !ok {
@ -256,6 +261,29 @@ func (h *Handler) SetStatus(c *gin.Context) {
response.OK(c, item)
}
func (h *Handler) Recycle(c *gin.Context) {
var req recycleRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
response.BadRequest(c, "回收参数不正确")
return
}
// 回收只能以路径 pretty_id 定位,不能相信前端传用户 ID 或 lease ID真实占用关系由 user-service 事务重新锁表校验。
prettyID := strings.TrimSpace(c.Param("pretty_id"))
item, err := h.user.RecyclePrettyDisplayID(c.Request.Context(), userclient.RecyclePrettyDisplayIDRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
PrettyID: prettyID,
Reason: strings.TrimSpace(req.Reason),
OperatorAdminID: int64(middleware.CurrentUserID(c)),
})
if err != nil {
response.BadRequest(c, err.Error())
return
}
shared.OperationLogWithResourceID(c, h.audit, "recycle-pretty-id", "pretty_display_ids", prettyID, "success", "status="+item.Status)
response.OK(c, item)
}
func pageQuery(c *gin.Context) (int32, int32, bool) {
// 分页参数在 admin-server 先收敛到 1..100,避免后台页面传错值造成 user-service 大分页查询。
page := int32(1)

View File

@ -17,5 +17,6 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/admin/users/pretty-id-pools/:pool_id/generate", middleware.RequirePermission("pretty-id:generate"), h.Generate)
protected.GET("/admin/users/pretty-ids", middleware.RequirePermission("pretty-id:view"), h.ListIDs)
protected.POST("/admin/users/pretty-ids/grant", middleware.RequirePermission("pretty-id:grant"), h.Grant)
protected.POST("/admin/users/pretty-ids/:pretty_id/recycle", middleware.RequirePermission("pretty-id:update"), h.Recycle)
protected.POST("/admin/users/pretty-ids/:pretty_id/status", middleware.RequirePermission("pretty-id:update"), h.SetStatus)
}

View File

@ -569,6 +569,16 @@ type PrettyDisplayIDStatusCommand struct {
NowMs int64
}
// PrettyDisplayIDRecycleCommand 描述后台强制回收用户当前靓号的输入。
type PrettyDisplayIDRecycleCommand struct {
AppCode string
PrettyID string
Reason string
OperatorAdminID int64
RequestID string
NowMs int64
}
// PrettyDisplayIDPoolApplyCommand 描述 App 靓号池申请事务输入。
type PrettyDisplayIDPoolApplyCommand struct {
AppCode string

View File

@ -1634,3 +1634,66 @@ func TestPasswordLoginAllowsAlphanumericPrettyDisplayID(t *testing.T) {
t.Fatalf("unexpected alphanumeric pretty login token: %+v", prettyToken)
}
}
func TestRecycleAdminGrantedPrettyDisplayIDRestoresDefaultDisplayID(t *testing.T) {
// 后台回收必须能处理 duration_ms=0 的长期靓号;这类靓号不能复用到期恢复接口,否则会被未到期校验拒绝。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
authSvc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})
userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-pretty-recycle", thirdPartyRegistration("ios"), authservice.Meta{})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
if err := authSvc.SetPassword(ctx, token.UserID, "secret-pass", authservice.Meta{}); err != nil {
t.Fatalf("SetPassword failed: %v", err)
}
identity, _, err := userSvc.AdminGrantPrettyDisplayID(ctx, userdomain.AdminGrantPrettyDisplayIDCommand{
TargetUserID: token.UserID,
DisplayUserID: "RECYCLE2026",
OperatorAdminID: 1,
RequestID: "req-admin-pretty-recycle-grant",
})
if err != nil {
t.Fatalf("AdminGrantPrettyDisplayID failed: %v", err)
}
if identity.PrettyID == "" || identity.DisplayUserID != "RECYCLE2026" {
t.Fatalf("unexpected grant identity: %+v", identity)
}
now = time.UnixMilli(2000)
recycled, err := userSvc.RecyclePrettyDisplayID(ctx, userdomain.PrettyDisplayIDRecycleCommand{
PrettyID: identity.PrettyID,
Reason: "manual test recycle",
OperatorAdminID: 2,
RequestID: "req-admin-pretty-recycle",
})
if err != nil {
t.Fatalf("RecyclePrettyDisplayID failed: %v", err)
}
if recycled.Status != userdomain.PrettyDisplayIDStatusReleased || recycled.AssignedUserID != 0 || recycled.AssignedLeaseID != "" {
// 后台自由发放号回收后保留 released 历史态,不回到池内可申请。
t.Fatalf("unexpected recycled pretty item: %+v", recycled)
}
userAfterRecycle, err := repository.GetUser(ctx, token.UserID)
if err != nil {
t.Fatalf("GetUser after recycle failed: %v", err)
}
if userAfterRecycle.CurrentDisplayUserID != token.DefaultDisplayUserID || userAfterRecycle.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindDefault {
t.Fatalf("user must recover default display id after recycle: %+v", userAfterRecycle)
}
defaultToken, err := authSvc.LoginPassword(ctx, token.DefaultDisplayUserID, "secret-pass", "ios", authservice.Meta{})
if err != nil {
t.Fatalf("default display_user_id login after recycle failed: %v", err)
}
if defaultToken.DisplayUserID != token.DefaultDisplayUserID || defaultToken.DisplayUserIDKind != string(userdomain.DisplayUserIDKindDefault) {
t.Fatalf("unexpected default token after recycle: %+v", defaultToken)
}
if _, err := authSvc.LoginPassword(ctx, "RECYCLE2026", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
t.Fatalf("recycled pretty display_user_id must not login, got %v", err)
}
}

View File

@ -270,6 +270,22 @@ func (s *Service) SetPrettyDisplayIDStatus(ctx context.Context, command userdoma
return s.identityRepository.SetPrettyDisplayIDStatus(ctx, command)
}
// RecyclePrettyDisplayID 后台强制回收用户正在占用的靓号,并恢复该用户默认短号。
func (s *Service) RecyclePrettyDisplayID(ctx context.Context, command userdomain.PrettyDisplayIDRecycleCommand) (userdomain.PrettyDisplayID, error) {
if s.identityRepository == nil {
return userdomain.PrettyDisplayID{}, xerr.New(xerr.Unavailable, "identity repository is not configured")
}
// 回收是后台写操作,只接受 pretty_id 定位当前占用记录用户、lease 和来源都在 repository 事务内重新读取校验。
command.AppCode = appcode.FromContext(ctx)
command.PrettyID = strings.TrimSpace(command.PrettyID)
command.Reason = strings.TrimSpace(command.Reason)
command.NowMs = s.now().UnixMilli()
if command.PrettyID == "" || command.OperatorAdminID <= 0 {
return userdomain.PrettyDisplayID{}, xerr.New(xerr.InvalidArgument, "pretty display id recycle argument is invalid")
}
return s.identityRepository.RecyclePrettyDisplayID(ctx, command)
}
// AdminGrantPrettyDisplayID 后台直接发放靓号。
func (s *Service) AdminGrantPrettyDisplayID(ctx context.Context, command userdomain.AdminGrantPrettyDisplayIDCommand) (userdomain.Identity, string, error) {
if s.identityRepository == nil {

View File

@ -137,6 +137,8 @@ type IdentityRepository interface {
ListPrettyDisplayIDs(ctx context.Context, query userdomain.PrettyDisplayIDQuery) ([]userdomain.PrettyDisplayID, int64, error)
// SetPrettyDisplayIDStatus 切换未占用靓号的状态。
SetPrettyDisplayIDStatus(ctx context.Context, command userdomain.PrettyDisplayIDStatusCommand) (userdomain.PrettyDisplayID, error)
// RecyclePrettyDisplayID 后台强制回收用户当前占用的靓号。
RecyclePrettyDisplayID(ctx context.Context, command userdomain.PrettyDisplayIDRecycleCommand) (userdomain.PrettyDisplayID, error)
// AdminGrantPrettyDisplayID 后台直接发放靓号并覆盖当前展示号。
AdminGrantPrettyDisplayID(ctx context.Context, command userdomain.AdminGrantPrettyDisplayIDCommand) (userdomain.Identity, string, error)
// ExpirePrettyDisplayUserID 原子恢复默认短号。

View File

@ -29,6 +29,23 @@ type prettyAssignment struct {
NowMs int64
}
type prettyReleaseCommand struct {
// LeaseID 为空时按用户当前 active lease 释放;非空时必须匹配当前 active lease防止旧请求误释放新靓号。
LeaseID string
// Reason 写入 lease、展示号、靓号池记录和变更日志用于区分到期、替换和后台回收。
Reason string
// ChangeType 是 display_user_id_change_logs 的业务类型。
ChangeType string
// LeaseStatus 表示租约释放后的生命周期状态。
LeaseStatus userdomain.PrettyLeaseStatus
// DisplayStatus 表示当前展示号占用释放后的状态,到期用 expired后台回收用 released。
DisplayStatus userdomain.DisplayUserIDStatus
// OperatorAdminID 只有后台回收这类管理动作存在;到期任务为 0。
OperatorAdminID int64
RequestID string
NowMs int64
}
// ApplyPrettyDisplayUserID 兼容旧付费靓号接口,仍要求租期和支付回执,但允许覆盖旧靓号。
func (r *Repository) ApplyPrettyDisplayUserID(ctx context.Context, command userdomain.PrettyDisplayUserIDCommand) (userdomain.Identity, string, error) {
// 旧接口按展示号申请,没有 pretty_id先懒过期同名到期靓号避免已过期记录继续占用唯一性。
@ -289,20 +306,99 @@ func (r *Repository) expirePrettyInTx(ctx context.Context, tx *sql.Tx, user user
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDLeaseExpired, "pretty display_user_id lease is not expired")
}
// 锁 active lease 后再释放lease_id 传入时作为精确校验,避免旧定时任务误释放后来替换的新租约。
activeLeaseID, _, err := lockActivePrettyLease(ctx, tx, appcode.FromContext(ctx), user.UserID)
return r.releaseCurrentPrettyInTx(ctx, tx, user, prettyReleaseCommand{
LeaseID: leaseID,
Reason: "pretty_expire",
ChangeType: "pretty_expire",
LeaseStatus: userdomain.PrettyLeaseStatusExpired,
DisplayStatus: userdomain.DisplayUserIDStatusExpired,
RequestID: requestID,
NowMs: nowMs,
})
}
// RecyclePrettyDisplayID 强制回收后台列表中仍处于 assigned 的靓号,并把用户恢复到默认短号。
func (r *Repository) RecyclePrettyDisplayID(ctx context.Context, command userdomain.PrettyDisplayIDRecycleCommand) (userdomain.PrettyDisplayID, error) {
appCode := appcode.Normalize(command.AppCode)
reason := strings.TrimSpace(command.Reason)
if reason == "" {
// 默认原因必须和到期恢复区分,方便审计和后续排查运营手动动作。
reason = "admin_recycle_pretty"
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return userdomain.PrettyDisplayID{}, err
}
defer tx.Rollback()
item, err := queryPrettyDisplayIDForUpdate(ctx, tx, appCode, strings.TrimSpace(command.PrettyID))
if err == sql.ErrNoRows {
return userdomain.PrettyDisplayID{}, xerr.New(xerr.NotFound, "pretty display id not found")
}
if err != nil {
return userdomain.PrettyDisplayID{}, err
}
if item.Status != userdomain.PrettyDisplayIDStatusAssigned || item.AssignedUserID <= 0 || item.AssignedLeaseID == "" {
// 只有真实占用中的号码才能回收;可用、禁用、预留和已释放状态仍走普通状态管理。
return userdomain.PrettyDisplayID{}, xerr.New(xerr.DisplayUserIDPrettyNotAvailable, "pretty display id is not assigned")
}
user, err := userstorage.QueryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", item.AssignedUserID)
if err == sql.ErrNoRows {
return userdomain.PrettyDisplayID{}, xerr.New(xerr.NotFound, "assigned user not found")
}
if err != nil {
return userdomain.PrettyDisplayID{}, err
}
if user.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindPretty || user.CurrentDisplayUserID != item.DisplayUserID {
// 列表记录和 users 当前展示号不一致时不能盲目清表,交给后续数据修复处理,避免回收错用户的新靓号。
return userdomain.PrettyDisplayID{}, xerr.New(xerr.DisplayUserIDLeaseExpired, "pretty display id lease is not active")
}
if _, err := r.releaseCurrentPrettyInTx(ctx, tx, user, prettyReleaseCommand{
LeaseID: item.AssignedLeaseID,
Reason: reason,
ChangeType: "admin_recycle_pretty",
LeaseStatus: userdomain.PrettyLeaseStatusCancelled,
DisplayStatus: userdomain.DisplayUserIDStatusReleased,
OperatorAdminID: command.OperatorAdminID,
RequestID: command.RequestID,
NowMs: command.NowMs,
}); err != nil {
return userdomain.PrettyDisplayID{}, err
}
if err := tx.Commit(); err != nil {
return userdomain.PrettyDisplayID{}, err
}
return queryPrettyDisplayID(ctx, r.db, appCode, item.PrettyID)
}
func (r *Repository) releaseCurrentPrettyInTx(ctx context.Context, tx *sql.Tx, user userdomain.User, command prettyReleaseCommand) (userdomain.Identity, error) {
appCode := appcode.FromContext(ctx)
// 先锁 active lease再按调用方传入的 lease_id 做精确校验;后台回收必须命中列表行里的 lease过期任务可以空 lease 幂等恢复。
activeLeaseID, _, err := lockActivePrettyLease(ctx, tx, appCode, user.UserID)
if err != nil {
return userdomain.Identity{}, err
}
if leaseID != "" && activeLeaseID != "" && leaseID != activeLeaseID {
if command.LeaseID != "" && activeLeaseID != command.LeaseID {
return userdomain.Identity{}, xerr.New(xerr.DisplayUserIDLeaseExpired, "pretty display_user_id lease is not active")
}
_, err = tx.ExecContext(ctx, `
UPDATE pretty_display_user_id_leases
SET status = ?, released_at_ms = ?, release_reason = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND status = ?
`, string(userdomain.PrettyLeaseStatusExpired), nowMs, "pretty_expire", nowMs, appcode.FromContext(ctx), user.UserID, string(userdomain.PrettyLeaseStatusActive))
if command.LeaseID != "" {
_, err = tx.ExecContext(ctx, `
UPDATE pretty_display_user_id_leases
SET status = ?, released_at_ms = ?, release_reason = ?, updated_at_ms = ?
WHERE app_code = ? AND lease_id = ? AND status = ?
`, string(command.LeaseStatus), command.NowMs, command.Reason, command.NowMs, appCode, command.LeaseID, string(userdomain.PrettyLeaseStatusActive))
} else {
_, err = tx.ExecContext(ctx, `
UPDATE pretty_display_user_id_leases
SET status = ?, released_at_ms = ?, release_reason = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND status = ?
`, string(command.LeaseStatus), command.NowMs, command.Reason, command.NowMs, appCode, user.UserID, string(userdomain.PrettyLeaseStatusActive))
}
if err != nil {
return userdomain.Identity{}, err
}
@ -311,23 +407,23 @@ func (r *Repository) expirePrettyInTx(ctx context.Context, tx *sql.Tx, user user
UPDATE user_display_user_ids
SET status = ?, released_at_ms = ?, release_reason = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND display_user_id = ? AND display_user_id_kind = ? AND status = ?
`, string(userdomain.DisplayUserIDStatusExpired), nowMs, "pretty_expire", nowMs, appcode.FromContext(ctx), user.UserID, user.CurrentDisplayUserID, string(userdomain.DisplayUserIDKindPretty), string(userdomain.DisplayUserIDStatusActive))
`, string(command.DisplayStatus), command.NowMs, command.Reason, command.NowMs, appCode, user.UserID, user.CurrentDisplayUserID, string(userdomain.DisplayUserIDKindPretty), string(userdomain.DisplayUserIDStatusActive))
if err != nil {
return userdomain.Identity{}, err
}
if activeLeaseID != "" {
// 池号和后台发放号释放后的状态不同,统一通过 lease 反查池表行处理。
if err := releasePrettyDisplayIDByLeaseInTx(ctx, tx, appcode.FromContext(ctx), activeLeaseID, nowMs, "pretty_expire"); err != nil {
if err := releasePrettyDisplayIDByLeaseInTx(ctx, tx, appCode, activeLeaseID, command.NowMs, command.Reason); err != nil {
return userdomain.Identity{}, err
}
}
// 默认短号在用户使用靓号期间保持 held过期恢复只把这条默认号重新激活,不重新生成短号。
// 默认短号在用户使用靓号期间保持 held释放时只把原默认号重新激活,不重新生成短号。
_, err = tx.ExecContext(ctx, `
UPDATE user_display_user_ids
SET status = ?, activated_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND display_user_id = ? AND display_user_id_kind = ? AND status = ?
`, string(userdomain.DisplayUserIDStatusActive), nowMs, nowMs, appcode.FromContext(ctx), user.UserID, user.DefaultDisplayUserID, string(userdomain.DisplayUserIDKindDefault), string(userdomain.DisplayUserIDStatusHeld))
`, string(userdomain.DisplayUserIDStatusActive), command.NowMs, command.NowMs, appCode, user.UserID, user.DefaultDisplayUserID, string(userdomain.DisplayUserIDKindDefault), string(userdomain.DisplayUserIDStatusHeld))
if err != nil {
return userdomain.Identity{}, err
}
@ -336,29 +432,30 @@ func (r *Repository) expirePrettyInTx(ctx context.Context, tx *sql.Tx, user user
UPDATE users
SET current_display_user_id = default_display_user_id, current_display_user_id_kind = ?, current_display_user_id_expires_at_ms = NULL, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, string(userdomain.DisplayUserIDKindDefault), nowMs, appcode.FromContext(ctx), user.UserID)
`, string(userdomain.DisplayUserIDKindDefault), command.NowMs, appCode, user.UserID)
if err != nil {
return userdomain.Identity{}, err
}
if err := insertDisplayUserIDChangeLog(ctx, tx, displayUserIDLog{
AppCode: appcode.FromContext(ctx),
AppCode: appCode,
UserID: user.UserID,
OldID: user.CurrentDisplayUserID,
NewID: user.DefaultDisplayUserID,
OldKind: userdomain.DisplayUserIDKindPretty,
NewKind: userdomain.DisplayUserIDKindDefault,
ChangeType: "pretty_expire",
ChangeType: command.ChangeType,
LeaseID: activeLeaseID,
Reason: "pretty_expire",
RequestID: requestID,
CreatedAtMs: nowMs,
Reason: command.Reason,
OperatorID: command.OperatorAdminID,
RequestID: command.RequestID,
CreatedAtMs: command.NowMs,
}); err != nil {
return userdomain.Identity{}, err
}
return userdomain.Identity{
AppCode: appcode.FromContext(ctx),
AppCode: appCode,
UserID: user.UserID,
DisplayUserID: user.DefaultDisplayUserID,
DefaultDisplayUserID: user.DefaultDisplayUserID,

View File

@ -249,6 +249,11 @@ func (r *Repository) SetPrettyDisplayIDStatus(ctx context.Context, command userd
return r.Repository.IdentityRepository().SetPrettyDisplayIDStatus(ctx, command)
}
// RecyclePrettyDisplayID 让测试 wrapper 继续满足后台靓号回收接口。
func (r *Repository) RecyclePrettyDisplayID(ctx context.Context, command userdomain.PrettyDisplayIDRecycleCommand) (userdomain.PrettyDisplayID, error) {
return r.Repository.IdentityRepository().RecyclePrettyDisplayID(ctx, command)
}
// AdminGrantPrettyDisplayID 让测试 wrapper 继续满足后台发放靓号接口。
func (r *Repository) AdminGrantPrettyDisplayID(ctx context.Context, command userdomain.AdminGrantPrettyDisplayIDCommand) (userdomain.Identity, string, error) {
return r.Repository.IdentityRepository().AdminGrantPrettyDisplayID(ctx, command)

View File

@ -1148,6 +1148,22 @@ func (s *Server) SetPrettyDisplayIDStatus(ctx context.Context, req *userv1.SetPr
return &userv1.PrettyDisplayIDResponse{Item: toProtoPrettyDisplayID(item)}, nil
}
// RecyclePrettyDisplayID 后台回收用户当前占用靓号并恢复默认短号。
func (s *Server) RecyclePrettyDisplayID(ctx context.Context, req *userv1.RecyclePrettyDisplayIDRequest) (*userv1.PrettyDisplayIDResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
// 回收允许处理长期靓号,不能复用 ExpirePrettyDisplayUserID 的到期校验;具体 lease 一致性由 user-service 事务完成。
item, err := s.userSvc.RecyclePrettyDisplayID(ctx, userdomain.PrettyDisplayIDRecycleCommand{
PrettyID: req.GetPrettyId(),
Reason: req.GetReason(),
OperatorAdminID: req.GetOperatorAdminId(),
RequestID: req.GetMeta().GetRequestId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.PrettyDisplayIDResponse{Item: toProtoPrettyDisplayID(item)}, nil
}
// AdminGrantPrettyDisplayID 后台直接给用户发放自由靓号。
func (s *Server) AdminGrantPrettyDisplayID(ctx context.Context, req *userv1.AdminGrantPrettyDisplayIDRequest) (*userv1.AdminGrantPrettyDisplayIDResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())