Implement friend APIs and target profile support

- Add friend search/list/request/requestCancel/response/expel handlers and schemas
- Store friend relationships in user_friend and backfill default friend links at startup
- Auto-add the default user as a friend for newly created accounts
- Support target user_id in /api profile requests and return target profile data
- Make profile accessory_info optional to avoid zero-value accessory payloads
- Update login topInfo friend counters from friend relationship state

Signed-off-by: Sean Du <do4suki@gmail.com>
This commit is contained in:
2026-06-05 01:54:59 +08:00
parent ec2c5a6dac
commit d48293be44
28 changed files with 1653 additions and 159 deletions
+2 -2
View File
@@ -76,7 +76,7 @@ func api(ctx *gin.Context) {
case "livese":
result, err = livese.LiveSeApi(v.Action)
case "login":
result, err = login.LoginApi(v.Action)
result, err = login.LoginApi(ctx, v.Action)
case "marathon":
result, err = marathon.MarathonApi(v.Action)
case "multiunit":
@@ -90,7 +90,7 @@ func api(ctx *gin.Context) {
case "payment":
result, err = payment.PaymentApi(v.Action)
case "profile":
result, err = profile.ProfileApi(ctx, v.Action)
result, err = profile.ProfileApi(ctx, v.Action, v.UserID)
case "scenario":
result, err = scenario.ScenarioApi(ctx, v.Action)
case "stamp":
+4 -2
View File
@@ -2,12 +2,14 @@ package login
import (
"fmt"
"github.com/gin-gonic/gin"
)
func LoginApi(action string) (res any, err error) {
func LoginApi(ctx *gin.Context, action string) (res any, err error) {
switch action {
case "topInfo":
res, err = loginTopInfo()
res, err = loginTopInfo(ctx)
case "topInfoOnce":
res, err = loginTopInfoOnce()
default:
+35 -8
View File
@@ -1,25 +1,52 @@
package login
import (
usermodel "honoka-chan/internal/model/user"
loginapischema "honoka-chan/internal/schema/api/login"
"honoka-chan/internal/session"
"time"
"github.com/gin-gonic/gin"
)
func loginTopInfo() (res any, err error) {
func loginTopInfo(ctx *gin.Context) (res any, err error) {
ss := session.Get(ctx)
now := time.Now()
friendsRequestCnt64, err := ss.UserEng.Table(new(usermodel.UserFriend)).
Where("user_id = ?", ss.UserID).
Where("status = ?", usermodel.FriendStatusAwaitingApproval).
Where("is_new = ?", true).
Count()
if err != nil {
return nil, err
}
friendsApprovalWaitCnt64, err := ss.UserEng.Table(new(usermodel.UserFriend)).
Where("user_id = ?", ss.UserID).
Where("status = ?", usermodel.FriendStatusPending).
Count()
if err != nil {
return nil, err
}
friendsRequestCnt := int(friendsRequestCnt64)
friendsApprovalWaitCnt := int(friendsApprovalWaitCnt64)
res = loginapischema.TopInfoResp{
Result: loginapischema.TopInfoData{
FriendActionCnt: 0,
FriendGreetCnt: 0,
FriendVarietyCnt: 0,
FriendNewCnt: 0,
FriendNewCnt: friendsRequestCnt + friendsApprovalWaitCnt,
PresentCnt: 0,
SecretBoxBadgeFlag: false,
ServerDatetime: time.Now().Format("2006-01-02 15:04:05"),
ServerTimestamp: time.Now().Unix(),
NoticeFriendDatetime: time.Now().Format("2006-01-02 15:04:05"),
ServerDatetime: now.Format("2006-01-02 15:04:05"),
ServerTimestamp: now.Unix(),
NoticeFriendDatetime: now.Format("2006-01-02 15:04:05"),
NoticeMailDatetime: "2000-01-01 12:00:00",
FriendsApprovalWaitCnt: 0,
FriendsRequestCnt: 0,
FriendsApprovalWaitCnt: friendsApprovalWaitCnt,
FriendsRequestCnt: friendsRequestCnt,
IsTodayBirthday: false,
LicenseInfo: loginapischema.LicenseInfo{
LicenseList: []any{},
@@ -37,7 +64,7 @@ func loginTopInfo() (res any, err error) {
},
Status: 200,
CommandNum: false,
TimeStamp: time.Now().Unix(),
TimeStamp: now.Unix(),
}
return res, err
+329 -108
View File
@@ -1,6 +1,7 @@
package profile
import (
"errors"
unitmodel "honoka-chan/internal/model/unit"
usermodel "honoka-chan/internal/model/user"
profileapischema "honoka-chan/internal/schema/api/profile"
@@ -11,148 +12,368 @@ import (
"github.com/gin-gonic/gin"
)
func profileInfo(ctx *gin.Context) (res any, err error) {
func profileInfo(ctx *gin.Context, targetUserID int) (res any, err error) {
ss := session.Get(ctx)
pref := ss.UserPref
displayUserID := pref.UserID
if inviteUserID, convErr := strconv.Atoi(pref.InviteCode); convErr == nil && inviteUserID > 0 {
displayUserID = inviteUserID
targetPref, err := getTargetUserPref(ss, targetUserID)
if err != nil {
return nil, err
}
targetUserID = targetPref.UserID
unitCount, err := ss.UserEng.Table(new(usermodel.UserUnitData)).
Where("user_id = ?", ss.UserID).Count()
Where("user_id = ?", targetUserID).
Count()
if err != nil {
return nil, err
}
unitData := unitmodel.UnitDataMap{}
_, err = ss.GetBasicUnitInfo().
Where("a.unit_owning_user_id = ?", pref.UnitOwningUserID).Get(&unitData)
lastLoginTime, err := getTargetLastLoginTime(ss, targetUserID)
if err != nil {
return nil, err
}
var accessoryOwningId, accessoryId, exp int
_, err = ss.UserEng.Table("user_accessory_wear").Where("unit_owning_user_id = ? AND user_id = ?", pref.UnitOwningUserID, ss.UserID).
Cols("accessory_owning_user_id").Get(&accessoryOwningId)
centerUnitOwningUserID, err := getMainDeckCenterUnitOwningUserID(ss, targetUserID, targetPref.UnitOwningUserID)
if err != nil {
return nil, err
}
_, err = ss.MainEng.Table("common_accessory_m").Where("accessory_owning_user_id = ?", accessoryOwningId).
Cols("accessory_id,exp").Get(&accessoryId, &exp)
if err != nil {
return nil, err
}
accessoryInfo := profileapischema.AccessoryInfo{
AccessoryOwningUserID: accessoryOwningId,
AccessoryID: accessoryId,
Exp: exp,
NextExp: 0,
Level: 8,
MaxLevel: 8,
RankUpCount: 4,
FavoriteFlag: true,
}
removeSkillIds := []int{}
err = ss.UserEng.Table("user_unit_skill_equip").Where("unit_owning_user_id = ? AND user_id = ?", pref.UnitOwningUserID, ss.UserID).
Cols("unit_removable_skill_id").Find(&removeSkillIds)
centerUnitInfo, centerRaw, err := getProfileCenterUnitInfo(ss, targetUserID, centerUnitOwningUserID, targetPref.AwardID)
if err != nil {
return nil, err
}
naviUnitInfo, naviRaw, err := getProfileNaviUnitInfo(ss, targetUserID, targetPref.UnitOwningUserID)
if err != nil {
return nil, err
}
if naviRaw != nil {
centerUnitInfo.Costume = profileapischema.Costume{
UnitID: naviRaw.UnitID,
IsRankMax: naviRaw.IsRankMax,
IsSigned: naviRaw.IsSigned,
}
} else if centerRaw != nil {
centerUnitInfo.Costume = profileapischema.Costume{
UnitID: centerRaw.UnitID,
IsRankMax: centerRaw.IsRankMax,
IsSigned: centerRaw.IsSigned,
}
}
res = profileapischema.InfoResp{
Result: profileapischema.InfoData{
UserInfo: profileapischema.UserInfo{
UserID: displayUserID,
Name: pref.UserName,
Level: pref.UserLevel,
UserID: targetPref.UserID,
Name: targetPref.UserName,
Level: targetPref.UserLevel,
CostMax: 100,
UnitMax: 5000,
EnergyMax: pref.EffectiveEnergyMax(),
EnergyMax: targetPref.EffectiveEnergyMax(),
FriendMax: 99,
UnitCnt: int(unitCount),
InviteCode: pref.InviteCode,
ElapsedTimeFromLogin: "14\u5c0f\u65f6\u524d",
Introduction: pref.UserDesc,
},
// TODO: 区分队伍中心卡片和看板卡片
CenterUnitInfo: profileapischema.CenterUnitInfo{
UnitOwningUserID: unitData.UnitOwningUserID,
UnitID: unitData.UnitID,
Exp: unitData.Exp,
NextExp: 0,
Level: unitData.Level,
LevelLimitID: unitData.LevelLimitID,
MaxLevel: unitData.MaxLevel,
Rank: unitData.Rank,
MaxRank: unitData.MaxRank,
Love: unitData.Love,
MaxLove: unitData.MaxLove,
UnitSkillLevel: unitData.UnitSkillLevel,
MaxHp: unitData.MaxHp,
FavoriteFlag: unitData.FavoriteFlag,
DisplayRank: unitData.DisplayRank,
UnitSkillExp: unitData.UnitSkillExp,
UnitRemovableSkillCapacity: unitData.UnitRemovableSkillCapacity,
Attribute: unitData.Attribute,
Smile: unitData.Smile,
Cute: unitData.Cute,
Cool: unitData.Cool,
IsLoveMax: unitData.IsLoveMax,
IsLevelMax: unitData.IsLevelMax,
IsRankMax: unitData.IsRankMax,
IsSigned: unitData.IsSigned,
IsSkillLevelMax: unitData.IsSkillLevelMax,
SettingAwardID: pref.AwardID,
RemovableSkillIds: removeSkillIds,
AccessoryInfo: accessoryInfo,
Costume: profileapischema.Costume{},
TotalSmile: unitData.Smile, // TODO: 加成计算
TotalCute: unitData.Cute, // 同上
TotalCool: unitData.Cool, // 同上
TotalHp: unitData.MaxHp,
},
NaviUnitInfo: profileapischema.NaviUnitInfo{
UnitOwningUserID: unitData.UnitOwningUserID,
UnitID: unitData.UnitID,
Exp: unitData.Exp,
NextExp: 0,
Level: unitData.Level,
MaxLevel: unitData.MaxLevel,
LevelLimitID: unitData.LevelLimitID,
Rank: unitData.Rank,
MaxRank: unitData.MaxRank,
Love: unitData.Love,
MaxLove: unitData.MaxLove,
UnitSkillExp: unitData.UnitSkillExp,
UnitSkillLevel: unitData.UnitSkillLevel,
MaxHp: unitData.MaxHp,
UnitRemovableSkillCapacity: unitData.UnitRemovableSkillCapacity,
FavoriteFlag: unitData.FavoriteFlag,
DisplayRank: unitData.DisplayRank,
IsLoveMax: unitData.IsLoveMax,
IsLevelMax: unitData.IsLevelMax,
IsRankMax: unitData.IsRankMax,
IsSigned: unitData.IsSigned,
IsSkillLevelMax: unitData.IsSkillLevelMax,
IsRemovableSkillCapacityMax: unitData.IsRemovableSkillCapacityMax,
InsertDate: "2016-10-11 10:33:03",
TotalSmile: unitData.Smile, // TODO: 加成计算
TotalCute: unitData.Cute, // 同上
TotalCool: unitData.Cool, // 同上
TotalHp: unitData.MaxHp,
RemovableSkillIds: removeSkillIds,
InviteCode: targetPref.InviteCode,
ElapsedTimeFromLogin: formatProfileElapsedTime(lastLoginTime),
Introduction: targetPref.UserDesc,
},
CenterUnitInfo: centerUnitInfo,
NaviUnitInfo: naviUnitInfo,
IsAlliance: false,
FriendStatus: 0,
SettingAwardID: pref.AwardID,
SettingBackgroundID: pref.BackgroundID,
SettingAwardID: targetPref.AwardID,
SettingBackgroundID: targetPref.BackgroundID,
},
Status: 200,
CommandNum: false,
TimeStamp: time.Now().Unix(),
}
return res, err
return res, nil
}
func getTargetUserPref(ss *session.Session, targetUserID int) (usermodel.UserPref, error) {
if targetUserID <= 0 || targetUserID == ss.UserID {
return ss.UserPref, nil
}
pref := usermodel.UserPref{}
has, err := ss.UserEng.Table(new(usermodel.UserPref)).
Where("user_id = ?", targetUserID).
Get(&pref)
if err != nil {
return usermodel.UserPref{}, err
}
if !has {
return usermodel.UserPref{}, errors.New("user not found")
}
if pref.NeedsProfileMigration() {
pref.ApplyProfileDefaults()
_, err = ss.UserEng.Table(new(usermodel.UserPref)).
Where("user_id = ?", targetUserID).
Cols(usermodel.UserPrefProfileColumns()...).
Update(&pref)
if err != nil {
return usermodel.UserPref{}, err
}
}
return pref, nil
}
func getTargetLastLoginTime(ss *session.Session, targetUserID int) (int64, error) {
if targetUserID == ss.UserID {
user := usermodel.Users{}
has, err := ss.UserEng.Table(new(usermodel.Users)).
Where("user_id = ?", targetUserID).
Get(&user)
if err != nil {
return 0, err
}
if !has {
return 0, errors.New("user not found")
}
return user.LastLoginTime, nil
}
user := usermodel.Users{}
has, err := ss.UserEng.Table(new(usermodel.Users)).
Where("user_id = ?", targetUserID).
Get(&user)
if err != nil {
return 0, err
}
if !has {
return 0, errors.New("user not found")
}
return user.LastLoginTime, nil
}
func getMainDeckCenterUnitOwningUserID(ss *session.Session, targetUserID, fallback int) (int, error) {
var unitOwningUserID int
has, err := ss.UserEng.Table(new(usermodel.UserDeckUnit)).Alias("udu").
Join("LEFT", "user_deck ud", "ud.id = udu.user_deck_id").
Where("udu.user_id = ?", targetUserID).
Where("ud.main_flag = ?", 1).
Where("udu.position = ?", 5).
Cols("udu.unit_owning_user_id").
Get(&unitOwningUserID)
if err != nil {
return 0, err
}
if has && unitOwningUserID > 0 {
return unitOwningUserID, nil
}
return fallback, nil
}
func getProfileCenterUnitInfo(ss *session.Session, targetUserID, unitOwningUserID, awardID int) (profileapischema.CenterUnitInfo, *unitmodel.UnitDataMap, error) {
unitData, err := getTargetUnitData(ss, targetUserID, unitOwningUserID)
if err != nil {
return profileapischema.CenterUnitInfo{}, nil, err
}
if unitData == nil {
return profileapischema.CenterUnitInfo{}, nil, nil
}
accessoryInfo, err := getAccessoryInfo(ss, targetUserID, unitOwningUserID)
if err != nil {
return profileapischema.CenterUnitInfo{}, nil, err
}
removeSkillIDs, err := getRemovableSkillIDs(ss, targetUserID, unitOwningUserID)
if err != nil {
return profileapischema.CenterUnitInfo{}, nil, err
}
info := profileapischema.CenterUnitInfo{
UnitOwningUserID: unitData.UnitOwningUserID,
UnitID: unitData.UnitID,
Exp: unitData.Exp,
NextExp: 0,
Level: unitData.Level,
LevelLimitID: unitData.LevelLimitID,
MaxLevel: unitData.MaxLevel,
Rank: unitData.Rank,
MaxRank: unitData.MaxRank,
Love: unitData.Love,
MaxLove: unitData.MaxLove,
UnitSkillLevel: unitData.UnitSkillLevel,
MaxHp: unitData.MaxHp,
FavoriteFlag: unitData.FavoriteFlag,
DisplayRank: unitData.DisplayRank,
UnitSkillExp: unitData.UnitSkillExp,
UnitRemovableSkillCapacity: unitData.UnitRemovableSkillCapacity,
Attribute: unitData.Attribute,
Smile: unitData.Smile,
Cute: unitData.Cute,
Cool: unitData.Cool,
IsLoveMax: unitData.IsLoveMax,
IsLevelMax: unitData.IsLevelMax,
IsRankMax: unitData.IsRankMax,
IsSigned: unitData.IsSigned,
IsSkillLevelMax: unitData.IsSkillLevelMax,
SettingAwardID: awardID,
RemovableSkillIds: removeSkillIDs,
AccessoryInfo: accessoryInfo,
TotalSmile: unitData.Smile,
TotalCute: unitData.Cute,
TotalCool: unitData.Cool,
TotalHp: unitData.MaxHp,
}
return info, unitData, nil
}
func getProfileNaviUnitInfo(ss *session.Session, targetUserID, unitOwningUserID int) (profileapischema.NaviUnitInfo, *unitmodel.UnitDataMap, error) {
unitData, err := getTargetUnitData(ss, targetUserID, unitOwningUserID)
if err != nil {
return profileapischema.NaviUnitInfo{}, nil, err
}
if unitData == nil {
return profileapischema.NaviUnitInfo{}, nil, nil
}
removeSkillIDs, err := getRemovableSkillIDs(ss, targetUserID, unitOwningUserID)
if err != nil {
return profileapischema.NaviUnitInfo{}, nil, err
}
info := profileapischema.NaviUnitInfo{
UnitOwningUserID: unitData.UnitOwningUserID,
UnitID: unitData.UnitID,
Exp: unitData.Exp,
NextExp: 0,
Level: unitData.Level,
MaxLevel: unitData.MaxLevel,
LevelLimitID: unitData.LevelLimitID,
Rank: unitData.Rank,
MaxRank: unitData.MaxRank,
Love: unitData.Love,
MaxLove: unitData.MaxLove,
UnitSkillExp: unitData.UnitSkillExp,
UnitSkillLevel: unitData.UnitSkillLevel,
MaxHp: unitData.MaxHp,
UnitRemovableSkillCapacity: unitData.UnitRemovableSkillCapacity,
FavoriteFlag: unitData.FavoriteFlag,
DisplayRank: unitData.DisplayRank,
IsRankMax: unitData.IsRankMax,
IsLoveMax: unitData.IsLoveMax,
IsLevelMax: unitData.IsLevelMax,
IsSigned: unitData.IsSigned,
IsSkillLevelMax: unitData.IsSkillLevelMax,
IsRemovableSkillCapacityMax: unitData.IsRemovableSkillCapacityMax,
InsertDate: "2016-10-11 10:33:03",
TotalSmile: unitData.Smile,
TotalCute: unitData.Cute,
TotalCool: unitData.Cool,
TotalHp: unitData.MaxHp,
RemovableSkillIds: removeSkillIDs,
}
return info, unitData, nil
}
func getTargetUnitData(ss *session.Session, targetUserID, unitOwningUserID int) (*unitmodel.UnitDataMap, error) {
if unitOwningUserID <= 0 {
return nil, nil
}
unitData := unitmodel.UnitDataMap{}
has, err := ss.GetBasicUnitInfo().
Where("a.user_id = ?", targetUserID).
Where("a.unit_owning_user_id = ?", unitOwningUserID).
Get(&unitData)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return &unitData, nil
}
func getAccessoryInfo(ss *session.Session, targetUserID, unitOwningUserID int) (*profileapischema.AccessoryInfo, error) {
if unitOwningUserID <= 0 {
return nil, nil
}
var accessoryOwningID int
has, err := ss.UserEng.Table("user_accessory_wear").
Where("unit_owning_user_id = ? AND user_id = ?", unitOwningUserID, targetUserID).
Cols("accessory_owning_user_id").
Get(&accessoryOwningID)
if err != nil {
return nil, err
}
if !has || accessoryOwningID <= 0 {
return nil, nil
}
accessoryData := struct {
AccessoryID int `xorm:"accessory_id"`
Exp int `xorm:"exp"`
}{}
has, err = ss.MainEng.Table("common_accessory_m").
Where("accessory_owning_user_id = ?", accessoryOwningID).
Cols("accessory_id,exp").
Get(&accessoryData)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return &profileapischema.AccessoryInfo{
AccessoryOwningUserID: accessoryOwningID,
AccessoryID: accessoryData.AccessoryID,
Exp: accessoryData.Exp,
NextExp: 0,
Level: 8,
MaxLevel: 8,
RankUpCount: 4,
FavoriteFlag: true,
}, nil
}
func getRemovableSkillIDs(ss *session.Session, targetUserID, unitOwningUserID int) ([]int, error) {
removeSkillIDs := []int{}
if unitOwningUserID <= 0 {
return removeSkillIDs, nil
}
err := ss.UserEng.Table("user_unit_skill_equip").
Where("unit_owning_user_id = ? AND user_id = ?", unitOwningUserID, targetUserID).
Cols("unit_removable_skill_id").
Find(&removeSkillIDs)
if err != nil {
return nil, err
}
return removeSkillIDs, nil
}
func formatProfileElapsedTime(ts int64) string {
if ts <= 0 {
return "刚刚"
}
delta := time.Since(time.Unix(ts, 0))
if delta < time.Minute {
return "刚刚"
}
if delta < time.Hour {
return timeDurationInt(delta.Minutes()) + "分钟前"
}
if delta < 24*time.Hour {
return timeDurationInt(delta.Hours()) + "小时前"
}
return timeDurationInt(delta.Hours()/24) + "天前"
}
func timeDurationInt(value float64) string {
return strconv.Itoa(int(value))
}
+2 -2
View File
@@ -6,14 +6,14 @@ import (
"github.com/gin-gonic/gin"
)
func ProfileApi(ctx *gin.Context, action string) (res any, err error) {
func ProfileApi(ctx *gin.Context, action string, targetUserID int) (res any, err error) {
switch action {
case "cardRanking":
res, err = cardRanking()
case "liveCnt":
res, err = liveCnt()
case "profileInfo":
res, err = profileInfo(ctx)
res, err = profileInfo(ctx, targetUserID)
default:
err = fmt.Errorf("unimplemented action: profile: %s", action)
}