Skip to content

Commit 0da9c1d

Browse files
committed
transfer owner of musicbill
1 parent 864cab5 commit 0da9c1d

16 files changed

Lines changed: 634 additions & 66 deletions

File tree

apps/cli/internal/api/apperr/codes.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ const (
3737
RepeatedSharedMusicbillInvitation = "repeated_shared_musicbill_invitation"
3838
NoPermissionToDeleteMusicbillSharedUser = "no_permission_to_delete_musicbill_shared_user"
3939
SharedMusicbillInvitationNotExisted = "shared_musicbill_invitation_not_existed"
40+
NotMusicbillOwner = "not_musicbill_owner"
41+
TargetUserNotAcceptedSharedUser = "target_user_not_accepted_shared_user"
4042
WrongUsernameOrPassword = "wrong_username_or_password"
4143
LackOf2FAToken = "lack_of_2fa_token"
4244
Wrong2FAToken = "wrong_2fa_token"

apps/cli/internal/api/handler/musicbill.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,70 @@ func GetSharedMusicbillInvitationList(c *gin.Context) {
574574
api.OK(c, list)
575575
}
576576

577+
type transferMusicbillOwnerBody struct {
578+
MusicbillID string `json:"musicbillId" binding:"required"`
579+
UserID string `json:"userId" binding:"required"`
580+
CaptchaID string `json:"captchaId" binding:"required"`
581+
CaptchaValue string `json:"captchaValue" binding:"required"`
582+
}
583+
584+
func TransferMusicbillOwner(c *gin.Context) {
585+
u := middleware.GetUser(c)
586+
var body transferMusicbillOwnerBody
587+
if err := c.ShouldBindJSON(&body); err != nil {
588+
api.Fail(c, apperr.WrongParameter)
589+
return
590+
}
591+
if body.UserID == u.ID {
592+
api.Fail(c, apperr.WrongParameter)
593+
return
594+
}
595+
596+
if !verifyCaptchaFromStore(body.CaptchaID, body.CaptchaValue) {
597+
api.Fail(c, apperr.WrongCaptcha)
598+
return
599+
}
600+
601+
mb, err := store.GetMusicbillByID(body.MusicbillID)
602+
if err != nil {
603+
api.Fail(c, apperr.MusicbillNotExisted)
604+
return
605+
}
606+
if mb.UserID != u.ID {
607+
api.Fail(c, apperr.NotMusicbillOwner)
608+
return
609+
}
610+
611+
if _, err := store.GetUserByID(body.UserID); err != nil {
612+
api.Fail(c, apperr.UserNotExisted)
613+
return
614+
}
615+
616+
sharedUsers, _ := store.GetSharedUsersInMusicbill(body.MusicbillID)
617+
targetAccepted := false
618+
for _, su := range sharedUsers {
619+
if su.SharedUserID == body.UserID && su.Accepted == 1 {
620+
targetAccepted = true
621+
break
622+
}
623+
}
624+
if !targetAccepted {
625+
api.Fail(c, apperr.TargetUserNotAcceptedSharedUser)
626+
return
627+
}
628+
629+
ok, err := store.TransferMusicbillOwner(body.MusicbillID, u.ID, body.UserID)
630+
if err != nil {
631+
api.Fail(c, apperr.ServerError)
632+
return
633+
}
634+
if !ok {
635+
api.Fail(c, apperr.NotMusicbillOwner)
636+
return
637+
}
638+
api.OK(c, nil)
639+
}
640+
577641
type acceptInvitationBody struct {
578642
ID int64 `json:"id" binding:"required"`
579643
}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package handler
2+
3+
import (
4+
"bytes"
5+
"cicada/internal/config"
6+
"cicada/internal/store"
7+
"encoding/json"
8+
"net/http"
9+
"net/http/httptest"
10+
"testing"
11+
"time"
12+
13+
"github.com/gin-gonic/gin"
14+
)
15+
16+
func setupTransferOwnerHandlerTest(t *testing.T) {
17+
t.Helper()
18+
gin.SetMode(gin.TestMode)
19+
if err := store.ResetForTests(); err != nil {
20+
t.Fatalf("reset store: %v", err)
21+
}
22+
t.Cleanup(func() {
23+
if err := store.ResetForTests(); err != nil {
24+
t.Fatalf("cleanup store: %v", err)
25+
}
26+
})
27+
config.Set(config.Config{
28+
Mode: config.ModeProduction,
29+
Data: t.TempDir(),
30+
Port: 8000,
31+
})
32+
if err := store.Initialize(); err != nil {
33+
t.Fatalf("initialize store: %v", err)
34+
}
35+
now := time.Now().UnixMilli()
36+
if _, err := store.DB().Exec(
37+
`INSERT INTO user (id,username,password,nickname,joinTimestamp) VALUES
38+
('owner','owner',?, 'Owner', ?),
39+
('shared','shared',?, 'Shared', ?),
40+
('pending','pending',?, 'Pending', ?),
41+
('stranger','stranger',?, 'Stranger', ?)`,
42+
store.DoubleMD5("password"), now,
43+
store.DoubleMD5("password"), now,
44+
store.DoubleMD5("password"), now,
45+
store.DoubleMD5("password"), now,
46+
); err != nil {
47+
t.Fatalf("insert users: %v", err)
48+
}
49+
if _, err := store.DB().Exec(
50+
`INSERT INTO musicbill (id,userId,name,createTimestamp) VALUES ('mb-1','owner','Bill',?)`,
51+
now,
52+
); err != nil {
53+
t.Fatalf("insert musicbill: %v", err)
54+
}
55+
if _, err := store.DB().Exec(
56+
`INSERT INTO shared_musicbill (musicbillId,sharedUserId,inviteUserId,inviteTimestamp,accepted) VALUES
57+
('mb-1','shared','owner',?,1),
58+
('mb-1','pending','owner',?,0)`,
59+
now, now,
60+
); err != nil {
61+
t.Fatalf("insert shared rows: %v", err)
62+
}
63+
}
64+
65+
// seedCaptcha 插入一条新鲜的 captcha, 返回 id+value 供 body 使用.
66+
func seedCaptcha(t *testing.T, id, value string) {
67+
t.Helper()
68+
if _, err := store.DB().Exec(
69+
`INSERT INTO captcha (id,value,createTimestamp) VALUES (?,?,?)`,
70+
id, value, time.Now().UnixMilli(),
71+
); err != nil {
72+
t.Fatalf("seed captcha: %v", err)
73+
}
74+
}
75+
76+
func callTransferOwner(t *testing.T, userID string, body map[string]any) (string, *httptest.ResponseRecorder) {
77+
t.Helper()
78+
w := httptest.NewRecorder()
79+
c, _ := gin.CreateTestContext(w)
80+
buf, err := json.Marshal(body)
81+
if err != nil {
82+
t.Fatalf("marshal body: %v", err)
83+
}
84+
req := httptest.NewRequest(http.MethodPut, "/api/musicbill/owner", bytes.NewReader(buf))
85+
req.Header.Set("Content-Type", "application/json")
86+
c.Request = req
87+
c.Set("authed_user", &store.User{ID: userID})
88+
TransferMusicbillOwner(c)
89+
90+
var parsed struct {
91+
Code string `json:"code"`
92+
}
93+
if err := json.Unmarshal(w.Body.Bytes(), &parsed); err != nil {
94+
t.Fatalf("decode response: %v", err)
95+
}
96+
return parsed.Code, w
97+
}
98+
99+
func TestTransferMusicbillOwnerHandler(t *testing.T) {
100+
t.Run("success", func(t *testing.T) {
101+
setupTransferOwnerHandlerTest(t)
102+
seedCaptcha(t, "cap-ok", "abcd")
103+
104+
code, _ := callTransferOwner(t, "owner", map[string]any{
105+
"musicbillId": "mb-1",
106+
"userId": "shared",
107+
"captchaId": "cap-ok",
108+
"captchaValue": "abcd",
109+
})
110+
if code != "success" {
111+
t.Fatalf("expected success, got %q", code)
112+
}
113+
mb, _ := store.GetMusicbillByID("mb-1")
114+
if mb.UserID != "shared" {
115+
t.Fatalf("expected owner=shared, got %q", mb.UserID)
116+
}
117+
})
118+
119+
t.Run("wrong captcha", func(t *testing.T) {
120+
setupTransferOwnerHandlerTest(t)
121+
seedCaptcha(t, "cap-bad", "abcd")
122+
123+
code, _ := callTransferOwner(t, "owner", map[string]any{
124+
"musicbillId": "mb-1",
125+
"userId": "shared",
126+
"captchaId": "cap-bad",
127+
"captchaValue": "wrong",
128+
})
129+
if code != "wrong_captcha" {
130+
t.Fatalf("expected wrong_captcha, got %q", code)
131+
}
132+
})
133+
134+
t.Run("caller is not owner", func(t *testing.T) {
135+
setupTransferOwnerHandlerTest(t)
136+
seedCaptcha(t, "cap-x", "abcd")
137+
138+
code, _ := callTransferOwner(t, "stranger", map[string]any{
139+
"musicbillId": "mb-1",
140+
"userId": "shared",
141+
"captchaId": "cap-x",
142+
"captchaValue": "abcd",
143+
})
144+
if code != "not_musicbill_owner" {
145+
t.Fatalf("expected not_musicbill_owner, got %q", code)
146+
}
147+
})
148+
149+
t.Run("target is self", func(t *testing.T) {
150+
setupTransferOwnerHandlerTest(t)
151+
code, _ := callTransferOwner(t, "owner", map[string]any{
152+
"musicbillId": "mb-1",
153+
"userId": "owner",
154+
"captchaId": "cap-unused",
155+
"captchaValue": "abcd",
156+
})
157+
if code != "wrong_parameter" {
158+
t.Fatalf("expected wrong_parameter, got %q", code)
159+
}
160+
})
161+
162+
t.Run("target has not accepted", func(t *testing.T) {
163+
setupTransferOwnerHandlerTest(t)
164+
seedCaptcha(t, "cap-p", "abcd")
165+
166+
code, _ := callTransferOwner(t, "owner", map[string]any{
167+
"musicbillId": "mb-1",
168+
"userId": "pending",
169+
"captchaId": "cap-p",
170+
"captchaValue": "abcd",
171+
})
172+
if code != "target_user_not_accepted_shared_user" {
173+
t.Fatalf("expected target_user_not_accepted_shared_user, got %q", code)
174+
}
175+
})
176+
177+
t.Run("target not in shared list", func(t *testing.T) {
178+
setupTransferOwnerHandlerTest(t)
179+
seedCaptcha(t, "cap-s", "abcd")
180+
181+
code, _ := callTransferOwner(t, "owner", map[string]any{
182+
"musicbillId": "mb-1",
183+
"userId": "stranger",
184+
"captchaId": "cap-s",
185+
"captchaValue": "abcd",
186+
})
187+
if code != "target_user_not_accepted_shared_user" {
188+
t.Fatalf("expected target_user_not_accepted_shared_user, got %q", code)
189+
}
190+
})
191+
}

apps/cli/internal/apidoc/openapi.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,29 @@ func operations() []operation {
784784
SuccessExample: nil,
785785
ErrorCodes: []string{"wrong_parameter", "musicbill_not_existed", "no_permission_to_delete_musicbill_shared_user", "not_authorized"},
786786
},
787+
{
788+
Method: "PUT",
789+
Path: "/api/musicbill/owner",
790+
Summary: "Transfer musicbill owner",
791+
Description: "Transfer the musicbill owner to a user that has already accepted the share invitation. The previous owner is automatically kept as an accepted shared user.",
792+
Tags: []string{"Musicbill"},
793+
Auth: true,
794+
RequestBody: jsonRequestBody(
795+
objSchema(
796+
[]string{"musicbillId", "userId", "captchaId", "captchaValue"},
797+
map[string]any{
798+
"musicbillId": strSchema("Musicbill ID.", "musicbill-1"),
799+
"userId": strSchema("Target user ID (must be an accepted shared user).", "2"),
800+
"captchaId": strSchema("Captcha ID.", "9c4a0f42"),
801+
"captchaValue": strSchema("Captcha value.", "5k7n"),
802+
},
803+
),
804+
map[string]any{"musicbillId": "musicbill-1", "userId": "2", "captchaId": "9c4a0f42", "captchaValue": "5k7n"},
805+
),
806+
SuccessSchema: nil,
807+
SuccessExample: nil,
808+
ErrorCodes: []string{"wrong_parameter", "wrong_captcha", "musicbill_not_existed", "user_not_existed", "not_musicbill_owner", "target_user_not_accepted_shared_user", "server_error", "not_authorized"},
809+
},
787810
{
788811
Method: "GET",
789812
Path: "/api/shared_musicbill_invitation_list",

apps/cli/internal/server/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ func NewServer() *gin.Engine {
108108
// Shared musicbill
109109
api.POST("/musicbill/shared_user", auth(), handler.AddMusicbillSharedUser)
110110
api.DELETE("/musicbill/shared_user", auth(), handler.DeleteMusicbillSharedUser)
111+
api.PUT("/musicbill/owner", auth(), handler.TransferMusicbillOwner)
111112
api.GET("/shared_musicbill_invitation_list", auth(), handler.GetSharedMusicbillInvitationList)
112113
api.PUT("/shared_musicbill_invitation", auth(), handler.AcceptSharedMusicbillInvitation)
113114

apps/cli/internal/store/musicbill.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,58 @@ func AcceptInvitation(id int64, userID string) (bool, error) {
265265
return n > 0, nil
266266
}
267267

268+
// TransferMusicbillOwner 把乐单所有者从 fromUserID 转给 toUserID. toUserID 必须是已接受
269+
// 共享邀请的用户; 转让成功后, 旧 owner 自动作为已接受的共享用户保留访问权限.
270+
// 整个过程在事务中完成, 通过条件 UPDATE 防止并发重复转让.
271+
// 返回 false 表示当前 owner 已变 (并发或调用方不再是 owner) 而不视为错误.
272+
func TransferMusicbillOwner(musicbillID, fromUserID, toUserID string) (bool, error) {
273+
tx, err := DB().Begin()
274+
if err != nil {
275+
return false, err
276+
}
277+
defer tx.Rollback()
278+
279+
var accepted int
280+
if err := tx.QueryRow(
281+
`SELECT accepted FROM shared_musicbill WHERE musicbillId=? AND sharedUserId=?`,
282+
musicbillID, toUserID,
283+
).Scan(&accepted); err != nil || accepted != 1 {
284+
// 目标不再是已接受的共享用户 (handler 已预校验, 这里兜底并发).
285+
return false, nil
286+
}
287+
288+
res, err := tx.Exec(
289+
`UPDATE musicbill SET userId=? WHERE id=? AND userId=?`,
290+
toUserID, musicbillID, fromUserID,
291+
)
292+
if err != nil {
293+
return false, err
294+
}
295+
n, _ := res.RowsAffected()
296+
if n == 0 {
297+
return false, nil
298+
}
299+
300+
if _, err := tx.Exec(
301+
`DELETE FROM shared_musicbill WHERE musicbillId=? AND sharedUserId=?`,
302+
musicbillID, toUserID,
303+
); err != nil {
304+
return false, err
305+
}
306+
307+
if _, err := tx.Exec(
308+
`INSERT OR REPLACE INTO shared_musicbill (musicbillId,sharedUserId,inviteUserId,inviteTimestamp,accepted) VALUES (?,?,?,?,1)`,
309+
musicbillID, fromUserID, toUserID, time.Now().UnixMilli(),
310+
); err != nil {
311+
return false, err
312+
}
313+
314+
if err := tx.Commit(); err != nil {
315+
return false, err
316+
}
317+
return true, nil
318+
}
319+
268320
// Public musicbill collection
269321

270322
func GetPublicMusicbillByID(id string) (*MusicbillWithOwner, error) {

0 commit comments

Comments
 (0)