Skip to content

Commit b6395db

Browse files
committed
Add PATCH endpoint for partial profile updates
1 parent c0029f8 commit b6395db

File tree

4 files changed

+252
-35
lines changed

4 files changed

+252
-35
lines changed

OpenBioCardServer/Controllers/Classic/ClassicUserController.cs

Lines changed: 35 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -51,38 +51,60 @@ public async Task<IActionResult> GetProfile(string username)
5151
}
5252

5353
/// <summary>
54-
/// Update user profile (requires authentication)
54+
/// Patch user profile (Partial Update)
55+
/// Only updates fields present in the JSON body.
5556
/// </summary>
5657
[HttpPost("{username}")]
57-
public async Task<IActionResult> UpdateProfile(string username, [FromBody] ClassicProfile request)
58+
public async Task<IActionResult> PatchProfile(string username, [FromBody] ClassicProfilePatch request)
5859
{
5960
var token = GetTokenFromHeader();
6061

6162
if (string.IsNullOrEmpty(token))
62-
{
6363
return Unauthorized(new ClassicErrorResponse("Missing authentication token"));
64-
}
6564

6665
var (isValid, account) = await _authService.ValidateTokenAsync(token);
6766

68-
if (!isValid || account == null)
69-
{
67+
if (!isValid || account == null || account.UserName != username)
7068
return Unauthorized(new ClassicErrorResponse("Invalid token"));
71-
}
7269

73-
if (account.UserName != username)
70+
try
7471
{
75-
return Unauthorized(new ClassicErrorResponse("Invalid token"));
72+
var success = await _profileService.PatchProfileAsync(username, request);
73+
74+
if (!success)
75+
return NotFound(new ClassicErrorResponse("Profile not found"));
76+
77+
return Ok(new ClassicSuccessResponse(true));
7678
}
79+
catch (Exception)
80+
{
81+
return StatusCode(500, new ClassicErrorResponse("Profile update failed"));
82+
}
83+
}
84+
85+
/// <summary>
86+
/// Full Update user profile (Legacy/Full Replace)
87+
/// Replaces the entire profile with the provided data.
88+
/// </summary>
89+
[HttpPost("{username}/update")]
90+
public async Task<IActionResult> FullUpdateProfile(string username, [FromBody] ClassicProfile request)
91+
{
92+
var token = GetTokenFromHeader();
93+
94+
if (string.IsNullOrEmpty(token))
95+
return Unauthorized(new ClassicErrorResponse("Missing authentication token"));
96+
97+
var (isValid, account) = await _authService.ValidateTokenAsync(token);
98+
99+
if (!isValid || account == null || account.UserName != username)
100+
return Unauthorized(new ClassicErrorResponse("Invalid token"));
77101

78102
try
79103
{
80104
var success = await _profileService.UpdateProfileAsync(username, request);
81105

82106
if (!success)
83-
{
84107
return NotFound(new ClassicErrorResponse("Profile not found"));
85-
}
86108

87109
return Ok(new ClassicSuccessResponse(true));
88110
}
@@ -102,30 +124,19 @@ public async Task<IActionResult> ExportData(string username)
102124
var token = GetTokenFromHeader();
103125

104126
if (string.IsNullOrEmpty(token))
105-
{
106127
return Unauthorized(new ClassicErrorResponse("Missing authentication token"));
107-
}
108128

109129
var (isValid, account) = await _authService.ValidateTokenAsync(token);
110130

111-
if (!isValid || account == null)
112-
{
113-
return Unauthorized(new ClassicErrorResponse("Invalid token"));
114-
}
115-
116-
if (account.UserName != username)
117-
{
131+
if (!isValid || account == null || account.UserName != username)
118132
return Unauthorized(new ClassicErrorResponse("Invalid token"));
119-
}
120133

121134
try
122135
{
123136
var exportData = await _profileService.GetExportDataAsync(username, token);
124137

125138
if (exportData == null)
126-
{
127139
return NotFound(new ClassicErrorResponse("User data not found"));
128-
}
129140

130141
return Ok(exportData);
131142
}
@@ -145,30 +156,19 @@ public async Task<IActionResult> ImportData(string username, [FromBody] ClassicU
145156
var token = GetTokenFromHeader();
146157

147158
if (string.IsNullOrEmpty(token))
148-
{
149159
return Unauthorized(new ClassicErrorResponse("Missing authentication token"));
150-
}
151160

152161
var (isValid, account) = await _authService.ValidateTokenAsync(token);
153162

154-
if (!isValid || account == null)
155-
{
156-
return Unauthorized(new ClassicErrorResponse("Invalid token"));
157-
}
158-
159-
if (account.UserName != username)
160-
{
163+
if (!isValid || account == null || account.UserName != username)
161164
return Unauthorized(new ClassicErrorResponse("Invalid token"));
162-
}
163165

164166
try
165167
{
166168
var success = await _profileService.ImportDataAsync(username, request);
167169

168170
if (!success)
169-
{
170171
return BadRequest(new ClassicErrorResponse("Import failed or username mismatch"));
171-
}
172172

173173
return Ok(new ClassicSuccessResponse(true));
174174
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
using Newtonsoft.Json;
2+
3+
namespace OpenBioCardServer.Models.DTOs.Classic;
4+
5+
/// <summary>
6+
/// 用于增量更新的 Profile DTO
7+
/// 所有字段均为可空,Null 表示不修改该字段
8+
/// </summary>
9+
public class ClassicProfilePatch
10+
{
11+
[JsonProperty("username")]
12+
public string? Username { get; set; }
13+
14+
[JsonProperty("name")]
15+
public string? Name { get; set; }
16+
17+
[JsonProperty("pronouns")]
18+
public string? Pronouns { get; set; }
19+
20+
[JsonProperty("avatar")]
21+
public string? Avatar { get; set; }
22+
23+
[JsonProperty("bio")]
24+
public string? Bio { get; set; }
25+
26+
[JsonProperty("location")]
27+
public string? Location { get; set; }
28+
29+
[JsonProperty("website")]
30+
public string? Website { get; set; }
31+
32+
[JsonProperty("background")]
33+
public string? Background { get; set; }
34+
35+
[JsonProperty("currentCompany")]
36+
public string? CurrentCompany { get; set; }
37+
38+
[JsonProperty("currentCompanyLink")]
39+
public string? CurrentCompanyLink { get; set; }
40+
41+
[JsonProperty("currentSchool")]
42+
public string? CurrentSchool { get; set; }
43+
44+
[JsonProperty("currentSchoolLink")]
45+
public string? CurrentSchoolLink { get; set; }
46+
47+
// 集合类型:
48+
// Null = 不更新
49+
// Empty List [] = 清空列表
50+
51+
[JsonProperty("contacts")]
52+
public List<ClassicContact>? Contacts { get; set; }
53+
54+
[JsonProperty("socialLinks")]
55+
public List<ClassicSocialLink>? SocialLinks { get; set; }
56+
57+
[JsonProperty("projects")]
58+
public List<ClassicProject>? Projects { get; set; }
59+
60+
[JsonProperty("workExperiences")]
61+
public List<ClassicWorkExperience>? WorkExperiences { get; set; }
62+
63+
[JsonProperty("schoolExperiences")]
64+
public List<ClassicSchoolExperience>? SchoolExperiences { get; set; }
65+
66+
[JsonProperty("gallery")]
67+
public List<ClassicGalleryItem>? Gallery { get; set; }
68+
}

OpenBioCardServer/Services/ClassicProfileService.cs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,4 +175,107 @@ public async Task<bool> UpdateProfileAsync(string username, ClassicProfile reque
175175
throw; // Re-throw to let controller handle the 500 response
176176
}
177177
}
178+
179+
/// <summary>
180+
/// 增量更新用户 Profile (Smart Update)
181+
/// </summary>
182+
public async Task<bool> PatchProfileAsync(string username, ClassicProfilePatch patch)
183+
{
184+
using var transaction = await _context.Database.BeginTransactionAsync();
185+
186+
try
187+
{
188+
var profile = await _context.Profiles
189+
.AsTracking()
190+
.FirstOrDefaultAsync(p => p.Username == username);
191+
192+
if (profile == null)
193+
{
194+
_logger.LogWarning("Attempted to patch non-existent profile: {Username}", username);
195+
return false;
196+
}
197+
198+
// 1. Update basic profile fields (Only non-nulls)
199+
ClassicMapper.UpdateProfileFromPatch(profile, patch);
200+
201+
// 2. Handle Collections
202+
// 逻辑:如果 Patch 中的集合为 null,则跳过(保持原样)。
203+
// 如果 Patch 中的集合不为 null (即使是空列表),则替换原有集合。
204+
205+
if (patch.Contacts != null)
206+
{
207+
await _context.ContactItems.Where(c => c.ProfileId == profile.Id).ExecuteDeleteAsync();
208+
if (patch.Contacts.Any())
209+
{
210+
var items = ClassicMapper.ToContactEntities(patch.Contacts, profile.Id);
211+
await _context.ContactItems.AddRangeAsync(items);
212+
}
213+
}
214+
215+
if (patch.SocialLinks != null)
216+
{
217+
await _context.SocialLinkItems.Where(s => s.ProfileId == profile.Id).ExecuteDeleteAsync();
218+
if (patch.SocialLinks.Any())
219+
{
220+
var items = ClassicMapper.ToSocialLinkEntities(patch.SocialLinks, profile.Id);
221+
await _context.SocialLinkItems.AddRangeAsync(items);
222+
}
223+
}
224+
225+
if (patch.Projects != null)
226+
{
227+
await _context.ProjectItems.Where(p => p.ProfileId == profile.Id).ExecuteDeleteAsync();
228+
if (patch.Projects.Any())
229+
{
230+
var items = ClassicMapper.ToProjectEntities(patch.Projects, profile.Id);
231+
await _context.ProjectItems.AddRangeAsync(items);
232+
}
233+
}
234+
235+
if (patch.WorkExperiences != null)
236+
{
237+
await _context.WorkExperienceItems.Where(w => w.ProfileId == profile.Id).ExecuteDeleteAsync();
238+
if (patch.WorkExperiences.Any())
239+
{
240+
var items = ClassicMapper.ToWorkExperienceEntities(patch.WorkExperiences, profile.Id);
241+
await _context.WorkExperienceItems.AddRangeAsync(items);
242+
}
243+
}
244+
245+
if (patch.SchoolExperiences != null)
246+
{
247+
await _context.SchoolExperienceItems.Where(s => s.ProfileId == profile.Id).ExecuteDeleteAsync();
248+
if (patch.SchoolExperiences.Any())
249+
{
250+
var items = ClassicMapper.ToSchoolExperienceEntities(patch.SchoolExperiences, profile.Id);
251+
await _context.SchoolExperienceItems.AddRangeAsync(items);
252+
}
253+
}
254+
255+
if (patch.Gallery != null)
256+
{
257+
await _context.GalleryItems.Where(g => g.ProfileId == profile.Id).ExecuteDeleteAsync();
258+
if (patch.Gallery.Any())
259+
{
260+
var items = ClassicMapper.ToGalleryEntities(patch.Gallery, profile.Id);
261+
await _context.GalleryItems.AddRangeAsync(items);
262+
}
263+
}
264+
265+
await _context.SaveChangesAsync();
266+
await transaction.CommitAsync();
267+
268+
// 3. Invalidate Cache
269+
await _cache.RemoveAsync(CacheKeys.GetClassicProfileCacheKey(username));
270+
271+
_logger.LogInformation("Profile patched successfully for user: {Username}", username);
272+
return true;
273+
}
274+
catch (Exception ex)
275+
{
276+
await transaction.RollbackAsync();
277+
_logger.LogError(ex, "Error patching profile for user: {Username}", username);
278+
throw;
279+
}
280+
}
178281
}

OpenBioCardServer/Utilities/Mappers/ClassicMapper.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,4 +359,50 @@ public static List<GalleryItemEntity> ToGalleryEntities(List<ClassicGalleryItem>
359359

360360
return null;
361361
}
362+
// === Patch Update Logic ===
363+
364+
public static void UpdateProfileFromPatch(ProfileEntity profile, ClassicProfilePatch patch)
365+
{
366+
// 仅当字段不为 Null 时更新
367+
// 注意:如果前端传空字符串 "",这里会更新为空字符串,符合预期(清空字段)
368+
369+
if (patch.Username != null) profile.Username = patch.Username;
370+
if (patch.Name != null) profile.NickName = patch.Name;
371+
if (patch.Pronouns != null) profile.Pronouns = patch.Pronouns;
372+
373+
if (patch.Avatar != null)
374+
{
375+
var (avatarType, avatarText, avatarData) = ParseAsset(patch.Avatar);
376+
profile.AvatarType = avatarType;
377+
profile.AvatarText = avatarText;
378+
profile.AvatarData = avatarData;
379+
}
380+
381+
if (patch.Bio != null) profile.Description = patch.Bio;
382+
if (patch.Location != null) profile.Location = patch.Location;
383+
if (patch.Website != null) profile.Website = patch.Website;
384+
385+
if (patch.Background != null)
386+
{
387+
// 如果传空字符串,视为删除背景
388+
if (string.IsNullOrEmpty(patch.Background))
389+
{
390+
profile.BackgroundType = null;
391+
profile.BackgroundText = null;
392+
profile.BackgroundData = null;
393+
}
394+
else
395+
{
396+
var (bgType, bgText, bgData) = ParseAsset(patch.Background);
397+
profile.BackgroundType = bgType;
398+
profile.BackgroundText = bgText;
399+
profile.BackgroundData = bgData;
400+
}
401+
}
402+
403+
if (patch.CurrentCompany != null) profile.CurrentCompany = patch.CurrentCompany;
404+
if (patch.CurrentCompanyLink != null) profile.CurrentCompanyLink = patch.CurrentCompanyLink;
405+
if (patch.CurrentSchool != null) profile.CurrentSchool = patch.CurrentSchool;
406+
if (patch.CurrentSchoolLink != null) profile.CurrentSchoolLink = patch.CurrentSchoolLink;
407+
}
362408
}

0 commit comments

Comments
 (0)