From d48293be440f6c6f71f13ebe5450d1a1c3654d6d Mon Sep 17 00:00:00 2001 From: Sean Du Date: Fri, 5 Jun 2026 01:27:51 +0800 Subject: [PATCH] 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 --- internal/handler/api/api.go | 4 +- internal/handler/api/login/login.go | 6 +- internal/handler/api/login/topinfo.go | 43 ++- internal/handler/api/profile/info.go | 437 +++++++++++++++++------ internal/handler/api/profile/profile.go | 4 +- internal/handler/friend/common.go | 87 +++++ internal/handler/friend/expel.go | 54 +++ internal/handler/friend/list.go | 316 ++++++++++++++++ internal/handler/friend/request.go | 103 ++++++ internal/handler/friend/requestcancel.go | 63 ++++ internal/handler/friend/response.go | 68 ++++ internal/handler/friend/search.go | 187 ++++++++++ internal/handler/ghome/account/login.go | 10 + internal/handler/handler.go | 1 + internal/model/user/userfriend.go | 107 ++++++ internal/schema/api/api.go | 1 + internal/schema/api/profile/info.go | 68 ++-- internal/schema/friend/common.go | 10 + internal/schema/friend/expel.go | 7 + internal/schema/friend/list.go | 84 +++++ internal/schema/friend/request.go | 11 + internal/schema/friend/requestcancel.go | 11 + internal/schema/friend/response.go | 7 + internal/schema/friend/search.go | 77 ++++ internal/startup/database.go | 1 + internal/startup/defaultuser.go | 6 +- internal/startup/friend.go | 38 ++ internal/startup/startup.go | 1 + 28 files changed, 1653 insertions(+), 159 deletions(-) create mode 100644 internal/handler/friend/common.go create mode 100644 internal/handler/friend/expel.go create mode 100644 internal/handler/friend/list.go create mode 100644 internal/handler/friend/request.go create mode 100644 internal/handler/friend/requestcancel.go create mode 100644 internal/handler/friend/response.go create mode 100644 internal/handler/friend/search.go create mode 100644 internal/model/user/userfriend.go create mode 100644 internal/schema/friend/common.go create mode 100644 internal/schema/friend/expel.go create mode 100644 internal/schema/friend/list.go create mode 100644 internal/schema/friend/request.go create mode 100644 internal/schema/friend/requestcancel.go create mode 100644 internal/schema/friend/response.go create mode 100644 internal/schema/friend/search.go create mode 100644 internal/startup/friend.go diff --git a/internal/handler/api/api.go b/internal/handler/api/api.go index a49e75c..4c9d0d2 100644 --- a/internal/handler/api/api.go +++ b/internal/handler/api/api.go @@ -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": diff --git a/internal/handler/api/login/login.go b/internal/handler/api/login/login.go index 5ea6f16..1aeca3c 100644 --- a/internal/handler/api/login/login.go +++ b/internal/handler/api/login/login.go @@ -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: diff --git a/internal/handler/api/login/topinfo.go b/internal/handler/api/login/topinfo.go index bfb9ede..b859655 100644 --- a/internal/handler/api/login/topinfo.go +++ b/internal/handler/api/login/topinfo.go @@ -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 diff --git a/internal/handler/api/profile/info.go b/internal/handler/api/profile/info.go index 709a6b4..19aff44 100644 --- a/internal/handler/api/profile/info.go +++ b/internal/handler/api/profile/info.go @@ -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)) } diff --git a/internal/handler/api/profile/profile.go b/internal/handler/api/profile/profile.go index 5454534..ca2d0f5 100644 --- a/internal/handler/api/profile/profile.go +++ b/internal/handler/api/profile/profile.go @@ -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) } diff --git a/internal/handler/friend/common.go b/internal/handler/friend/common.go new file mode 100644 index 0000000..1447271 --- /dev/null +++ b/internal/handler/friend/common.go @@ -0,0 +1,87 @@ +package friend + +import ( + "errors" + usermodel "honoka-chan/internal/model/user" + "honoka-chan/internal/session" + "strconv" + "strings" +) + +func resolveActualFriendUserID(ss *session.Session, requestedUserID int) (int, error) { + if requestedUserID <= 0 { + return 0, errors.New("invalid user_id") + } + + pref := usermodel.UserPref{} + has, err := ss.UserEng.Table(new(usermodel.UserPref)). + Where("invite_code = ?", strconv.Itoa(requestedUserID)). + Cols("user_id"). + Get(&pref) + if err != nil { + return 0, err + } + if has && pref.UserID > 0 { + return pref.UserID, nil + } + + user := usermodel.Users{} + has, err = ss.UserEng.Table(new(usermodel.Users)). + Where("user_id = ?", requestedUserID). + Cols("user_id"). + Get(&user) + if err != nil { + return 0, err + } + if !has || user.UserID <= 0 { + return 0, errors.New("user not found") + } + + return user.UserID, nil +} + +func areUsersFriends(ss *session.Session, userID, friendUserID int) (bool, error) { + forward, err := ss.UserEng.Table(new(usermodel.UserFriend)). + Where("user_id = ?", userID). + Where("friend_user_id = ?", friendUserID). + Where("status = ?", usermodel.FriendStatusApproved). + Exist() + if err != nil { + return false, err + } + if !forward { + return false, nil + } + + return ss.UserEng.Table(new(usermodel.UserFriend)). + Where("user_id = ?", friendUserID). + Where("friend_user_id = ?", userID). + Where("status = ?", usermodel.FriendStatusApproved). + Exist() +} + +func resolveUserIDByInviteCode(ss *session.Session, inviteCode string) (int, error) { + digits := strings.Map(func(r rune) rune { + if r >= '0' && r <= '9' { + return r + } + return -1 + }, inviteCode) + if digits == "" { + return 0, errors.New("invalid invite_code") + } + + pref := usermodel.UserPref{} + has, err := ss.UserEng.Table(new(usermodel.UserPref)). + Where("invite_code = ?", digits). + Cols("user_id"). + Get(&pref) + if err != nil { + return 0, err + } + if !has || pref.UserID <= 0 { + return 0, errors.New("user not found") + } + + return pref.UserID, nil +} diff --git a/internal/handler/friend/expel.go b/internal/handler/friend/expel.go new file mode 100644 index 0000000..04aff7d --- /dev/null +++ b/internal/handler/friend/expel.go @@ -0,0 +1,54 @@ +package friend + +import ( + "honoka-chan/internal/middleware" + usermodel "honoka-chan/internal/model/user" + "honoka-chan/internal/router" + friendschema "honoka-chan/internal/schema/friend" + "honoka-chan/internal/session" + honokautils "honoka-chan/internal/utils" + + "github.com/gin-gonic/gin" +) + +func expel(ctx *gin.Context) { + ss := session.Get(ctx) + defer ss.Finalize() + + req := friendschema.UserIDReq{} + err := honokautils.ParseRequestData(ctx, &req) + if ss.CheckErr(err) { + return + } + + targetUserID, err := resolveActualFriendUserID(ss, req.UserID) + if ss.CheckErr(err) { + return + } + + _, err = ss.UserEng.Table(new(usermodel.UserFriend)). + Where("user_id = ?", ss.UserID). + Where("friend_user_id = ?", targetUserID). + Delete() + if ss.CheckErr(err) { + return + } + + _, err = ss.UserEng.Table(new(usermodel.UserFriend)). + Where("user_id = ?", targetUserID). + Where("friend_user_id = ?", ss.UserID). + Delete() + if ss.CheckErr(err) { + return + } + + ss.Respond(friendschema.ExpelResp{ + ResponseData: []any{}, + ReleaseInfo: []any{}, + StatusCode: 200, + }) +} + +func init() { + router.AddHandler("main.php", "POST", "/friend/expel", middleware.Common, expel) +} diff --git a/internal/handler/friend/list.go b/internal/handler/friend/list.go new file mode 100644 index 0000000..a221c29 --- /dev/null +++ b/internal/handler/friend/list.go @@ -0,0 +1,316 @@ +package friend + +import ( + "errors" + "honoka-chan/internal/middleware" + unitmodel "honoka-chan/internal/model/unit" + usermodel "honoka-chan/internal/model/user" + "honoka-chan/internal/router" + friendschema "honoka-chan/internal/schema/friend" + "honoka-chan/internal/session" + honokautils "honoka-chan/internal/utils" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +type friendListRow struct { + FriendUserID int `xorm:"friend_user_id"` + Status int `xorm:"status"` + IsNew bool `xorm:"is_new"` + InsertDate int64 `xorm:"insert_date"` + UpdateDate int64 `xorm:"update_date"` + InviteCode string `xorm:"invite_code"` + UserName string `xorm:"user_name"` + UserLevel int `xorm:"user_level"` + UserDesc string `xorm:"user_desc"` + AwardID int `xorm:"award_id"` + LastLoginTime int64 `xorm:"last_login_time"` + CenterUnitOwningUserID int `xorm:"center_unit_owning_user_id"` +} + +func list(ctx *gin.Context) { + ss := session.Get(ctx) + defer ss.Finalize() + + listReq := friendschema.ListReq{} + err := honokautils.ParseRequestData(ctx, &listReq) + if ss.CheckErr(err) { + return + } + + friendStatus, err := mapFriendListTypeToStatus(listReq.Type) + if ss.CheckErr(err) { + return + } + + totalCount, err := ss.UserEng.Table(new(usermodel.UserFriend)). + Where("user_id = ?", ss.UserID). + Where("status = ?", friendStatus). + Count() + if ss.CheckErr(err) { + return + } + + page := max(listReq.Page, 0) + offset := page * usermodel.FriendListPageSize + + rows := []friendListRow{} + err = ss.UserEng.Table(new(usermodel.UserFriend)).Alias("uf"). + Join("LEFT", "user_pref up", "up.user_id = uf.friend_user_id"). + Join("LEFT", "users u", "u.user_id = uf.friend_user_id"). + Join("LEFT", "user_deck ud", "ud.user_id = uf.friend_user_id AND ud.main_flag = 1"). + Join("LEFT", "user_deck_unit udu", "udu.user_deck_id = ud.id AND udu.position = 5"). + Join("LEFT", "common_unit_data cud", "cud.unit_id = udu.unit_id"). + Where("uf.user_id = ?", ss.UserID). + Where("uf.status = ?", friendStatus). + Select(` + uf.friend_user_id, + uf.status, + uf.is_new, + uf.insert_date, + uf.update_date, + up.invite_code, + up.user_name, + up.user_level, + up.user_desc, + up.award_id, + u.last_login_time, + COALESCE(udu.unit_owning_user_id, up.unit_owning_user_id) AS center_unit_owning_user_id + `). + OrderBy(friendListOrderBy(listReq.Sort)). + Limit(usermodel.FriendListPageSize, offset). + Find(&rows) + if ss.CheckErr(err) { + return + } + + friendList := make([]friendschema.FriendList, 0, len(rows)) + for _, row := range rows { + item, err := buildFriendListItem(ss, row) + if ss.CheckErr(err) { + return + } + friendList = append(friendList, item) + } + + if len(rows) > 0 { + switch friendStatus { + case usermodel.FriendStatusApproved, usermodel.FriendStatusAwaitingApproval: + _, err = ss.UserEng.Table(new(usermodel.UserFriend)). + Where("user_id = ?", ss.UserID). + Where("status = ?", friendStatus). + Where("is_new = ?", true). + Cols("is_new", "update_date"). + Update(&usermodel.UserFriend{ + IsNew: false, + UpdateDate: time.Now().Unix(), + }) + if ss.CheckErr(err) { + return + } + } + } + + ss.Respond(friendschema.ListResp{ + ResponseData: friendschema.ListData{ + ItemCount: int(totalCount), + FriendList: friendList, + NewFriendList: []any{}, + ServerTimestamp: time.Now().Unix(), + }, + ReleaseInfo: []any{}, + StatusCode: 200, + }) +} + +func buildFriendListItem(ss *session.Session, row friendListRow) (friendschema.FriendList, error) { + appliedAt := max(row.UpdateDate, row.InsertDate) + + centerUnitInfo, err := buildCenterUnitInfo(ss, row.FriendUserID, row.CenterUnitOwningUserID, row.AwardID) + if err != nil { + return friendschema.FriendList{}, err + } + + return friendschema.FriendList{ + UserData: friendschema.UserData{ + UserID: displayFriendUserID(row.InviteCode, row.FriendUserID), + Name: row.UserName, + Level: row.UserLevel, + ElapsedTimeFromLogin: formatElapsedTime(row.LastLoginTime), + ElapsedTimeFromApplied: formatElapsedTime(appliedAt), + Comment: row.UserDesc, + }, + CenterUnitInfo: centerUnitInfo, + SettingAwardID: row.AwardID, + IsNew: row.IsNew, + }, nil +} + +func buildCenterUnitInfo(ss *session.Session, userID, unitOwningUserID, awardID int) (friendschema.CenterUnitInfo, error) { + info := friendschema.CenterUnitInfo{ + SettingAwardID: awardID, + RemovableSkillIds: []int{}, + } + if unitOwningUserID <= 0 { + return info, nil + } + + unitData := unitmodel.UnitDataMap{} + has, err := ss.GetBasicUnitInfo(). + Where("a.user_id = ?", userID). + Where("a.unit_owning_user_id = ?", unitOwningUserID). + Get(&unitData) + if err != nil { + return info, err + } + if !has { + return info, nil + } + + var accessoryInfo friendschema.AccessoryInfo + accessoryWear := usermodel.UserAccessoryWear{} + has, err = ss.UserEng.Table(new(usermodel.UserAccessoryWear)). + Where("user_id = ? AND unit_owning_user_id = ?", userID, unitOwningUserID). + Get(&accessoryWear) + if err != nil { + return info, err + } + if has && accessoryWear.AccessoryOwningUserID > 0 { + var accessoryID, exp int + _, err = ss.MainEng.Table("common_accessory_m"). + Where("accessory_owning_user_id = ?", accessoryWear.AccessoryOwningUserID). + Cols("accessory_id,exp"). + Get(&accessoryID, &exp) + if err != nil { + return info, err + } + accessoryInfo = friendschema.AccessoryInfo{ + AccessoryOwningUserID: accessoryWear.AccessoryOwningUserID, + AccessoryID: accessoryID, + Exp: exp, + NextExp: 0, + Level: 8, + MaxLevel: 8, + RankUpCount: 4, + FavoriteFlag: true, + } + } + + removableSkillIDs := []int{} + err = ss.UserEng.Table(new(usermodel.UserUnitSkillEquip)). + Where("user_id = ? AND unit_owning_user_id = ?", userID, unitOwningUserID). + Cols("unit_removable_skill_id"). + Find(&removableSkillIDs) + if err != nil { + return info, err + } + + info = friendschema.CenterUnitInfo{ + UnitOwningUserID: int64(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: removableSkillIDs, + AccessoryInfo: accessoryInfo, + } + + return info, nil +} + +func mapFriendListTypeToStatus(listType int) (int, error) { + switch listType { + case 0: + return usermodel.FriendStatusApproved, nil + case 1: + return usermodel.FriendStatusPending, nil + case 2: + return usermodel.FriendStatusAwaitingApproval, nil + default: + return 0, errors.New("invalid friend list type") + } +} + +func friendListOrderBy(sort int) string { + switch sort { + case 1: + return "up.user_level ASC" + case 2: + return "up.user_level DESC" + case 3: + return "cud.smile ASC" + case 4: + return "cud.smile DESC" + case 5: + return "cud.cool ASC" + case 6: + return "cud.cool DESC" + case 7: + return "cud.cute ASC" + case 8: + return "cud.cute DESC" + case 9: + return "u.last_login_time ASC" + case 10: + return "u.last_login_time DESC" + case 11: + return "uf.insert_date ASC" + case 12: + return "uf.insert_date DESC" + default: + return "uf.insert_date DESC" + } +} + +func displayFriendUserID(inviteCode string, fallback int) int { + if id, err := strconv.Atoi(inviteCode); err == nil && id > 0 { + return id + } + return fallback +} + +func formatElapsedTime(ts int64) string { + if ts <= 0 { + return "刚刚" + } + + delta := time.Since(time.Unix(ts, 0)) + if delta < time.Minute { + return "刚刚" + } + if delta < time.Hour { + return strconv.Itoa(int(delta.Minutes())) + "分钟前" + } + if delta < 24*time.Hour { + return strconv.Itoa(int(delta.Hours())) + "小时前" + } + return strconv.Itoa(int(delta.Hours())/24) + "天前" +} + +func init() { + router.AddHandler("main.php", "POST", "/friend/list", middleware.Common, list) +} diff --git a/internal/handler/friend/request.go b/internal/handler/friend/request.go new file mode 100644 index 0000000..1982af7 --- /dev/null +++ b/internal/handler/friend/request.go @@ -0,0 +1,103 @@ +package friend + +import ( + "errors" + "honoka-chan/internal/middleware" + usermodel "honoka-chan/internal/model/user" + "honoka-chan/internal/router" + friendschema "honoka-chan/internal/schema/friend" + "honoka-chan/internal/session" + honokautils "honoka-chan/internal/utils" + + "github.com/gin-gonic/gin" +) + +func request(ctx *gin.Context) { + ss := session.Get(ctx) + defer ss.Finalize() + + req := friendschema.UserIDReq{} + err := honokautils.ParseRequestData(ctx, &req) + if ss.CheckErr(err) { + return + } + + targetUserID, err := resolveActualFriendUserID(ss, req.UserID) + if ss.CheckErr(err) { + return + } + if targetUserID == ss.UserID { + ss.CheckErr(errors.New("cannot add self as friend")) + return + } + + isFriend, err := areUsersFriends(ss, ss.UserID, targetUserID) + if ss.CheckErr(err) { + return + } + if isFriend { + ss.Respond(friendschema.RequestResp{ + ResponseData: friendschema.RequestData{ + IsFriend: true, + }, + ReleaseInfo: []any{}, + StatusCode: 200, + }) + return + } + + reversePending, err := ss.UserEng.Table(new(usermodel.UserFriend)). + Where("user_id = ?", targetUserID). + Where("friend_user_id = ?", ss.UserID). + Where("status = ?", usermodel.FriendStatusPending). + Exist() + if ss.CheckErr(err) { + return + } + + selfAwaitingApproval, err := ss.UserEng.Table(new(usermodel.UserFriend)). + Where("user_id = ?", ss.UserID). + Where("friend_user_id = ?", targetUserID). + Where("status = ?", usermodel.FriendStatusAwaitingApproval). + Exist() + if ss.CheckErr(err) { + return + } + + if reversePending && selfAwaitingApproval { + err = usermodel.EnsureMutualFriendWithIsNew(ss.UserEng, ss.UserID, targetUserID, usermodel.FriendStatusApproved, true, true) + if ss.CheckErr(err) { + return + } + + ss.Respond(friendschema.RequestResp{ + ResponseData: friendschema.RequestData{ + IsFriend: true, + }, + ReleaseInfo: []any{}, + StatusCode: 200, + }) + return + } + + err = usermodel.EnsureFriendLink(ss.UserEng, ss.UserID, targetUserID, usermodel.FriendStatusPending, false) + if ss.CheckErr(err) { + return + } + err = usermodel.EnsureFriendLink(ss.UserEng, targetUserID, ss.UserID, usermodel.FriendStatusAwaitingApproval, true) + if ss.CheckErr(err) { + return + } + + ss.Respond(friendschema.RequestResp{ + ResponseData: friendschema.RequestData{ + IsFriend: false, + }, + ReleaseInfo: []any{}, + StatusCode: 200, + }) +} + +func init() { + router.AddHandler("main.php", "POST", "/friend/request", middleware.Common, request) +} diff --git a/internal/handler/friend/requestcancel.go b/internal/handler/friend/requestcancel.go new file mode 100644 index 0000000..ce6efb5 --- /dev/null +++ b/internal/handler/friend/requestcancel.go @@ -0,0 +1,63 @@ +package friend + +import ( + "honoka-chan/internal/middleware" + usermodel "honoka-chan/internal/model/user" + "honoka-chan/internal/router" + friendschema "honoka-chan/internal/schema/friend" + "honoka-chan/internal/session" + honokautils "honoka-chan/internal/utils" + + "github.com/gin-gonic/gin" +) + +func requestCancel(ctx *gin.Context) { + ss := session.Get(ctx) + defer ss.Finalize() + + req := friendschema.UserIDReq{} + err := honokautils.ParseRequestData(ctx, &req) + if ss.CheckErr(err) { + return + } + + targetUserID, err := resolveActualFriendUserID(ss, req.UserID) + if ss.CheckErr(err) { + return + } + + _, err = ss.UserEng.Table(new(usermodel.UserFriend)). + Where("user_id = ?", ss.UserID). + Where("friend_user_id = ?", targetUserID). + Where("status = ?", usermodel.FriendStatusPending). + Delete() + if ss.CheckErr(err) { + return + } + + _, err = ss.UserEng.Table(new(usermodel.UserFriend)). + Where("user_id = ?", targetUserID). + Where("friend_user_id = ?", ss.UserID). + Where("status = ?", usermodel.FriendStatusAwaitingApproval). + Delete() + if ss.CheckErr(err) { + return + } + + isFriend, err := areUsersFriends(ss, ss.UserID, targetUserID) + if ss.CheckErr(err) { + return + } + + ss.Respond(friendschema.RequestCancelResp{ + ResponseData: friendschema.RequestCancelData{ + IsFriend: isFriend, + }, + ReleaseInfo: []any{}, + StatusCode: 200, + }) +} + +func init() { + router.AddHandler("main.php", "POST", "/friend/requestCancel", middleware.Common, requestCancel) +} diff --git a/internal/handler/friend/response.go b/internal/handler/friend/response.go new file mode 100644 index 0000000..161c061 --- /dev/null +++ b/internal/handler/friend/response.go @@ -0,0 +1,68 @@ +package friend + +import ( + "errors" + "honoka-chan/internal/middleware" + usermodel "honoka-chan/internal/model/user" + "honoka-chan/internal/router" + friendschema "honoka-chan/internal/schema/friend" + "honoka-chan/internal/session" + honokautils "honoka-chan/internal/utils" + + "github.com/gin-gonic/gin" +) + +func response(ctx *gin.Context) { + ss := session.Get(ctx) + defer ss.Finalize() + + req := friendschema.ResponseReq{} + err := honokautils.ParseRequestData(ctx, &req) + if ss.CheckErr(err) { + return + } + + targetUserID, err := resolveActualFriendUserID(ss, req.UserID) + if ss.CheckErr(err) { + return + } + + switch req.Status { + case 0: + _, err = ss.UserEng.Table(new(usermodel.UserFriend)). + Where("user_id = ?", ss.UserID). + Where("friend_user_id = ?", targetUserID). + Where("status = ?", usermodel.FriendStatusAwaitingApproval). + Delete() + if ss.CheckErr(err) { + return + } + + _, err = ss.UserEng.Table(new(usermodel.UserFriend)). + Where("user_id = ?", targetUserID). + Where("friend_user_id = ?", ss.UserID). + Where("status = ?", usermodel.FriendStatusPending). + Delete() + if ss.CheckErr(err) { + return + } + case 2: + err = usermodel.EnsureMutualFriendWithIsNew(ss.UserEng, ss.UserID, targetUserID, usermodel.FriendStatusApproved, true, true) + if ss.CheckErr(err) { + return + } + default: + ss.CheckErr(errors.New("invalid friend response status")) + return + } + + ss.Respond(friendschema.ResponseResp{ + ResponseData: []any{}, + ReleaseInfo: []any{}, + StatusCode: 200, + }) +} + +func init() { + router.AddHandler("main.php", "POST", "/friend/response", middleware.Common, response) +} diff --git a/internal/handler/friend/search.go b/internal/handler/friend/search.go new file mode 100644 index 0000000..0abdfa2 --- /dev/null +++ b/internal/handler/friend/search.go @@ -0,0 +1,187 @@ +package friend + +import ( + "errors" + "honoka-chan/internal/middleware" + unitmodel "honoka-chan/internal/model/unit" + usermodel "honoka-chan/internal/model/user" + "honoka-chan/internal/router" + friendschema "honoka-chan/internal/schema/friend" + "honoka-chan/internal/session" + honokautils "honoka-chan/internal/utils" + "time" + + "github.com/gin-gonic/gin" +) + +type friendSearchRow struct { + UserID int `xorm:"user_id"` + InviteCode string `xorm:"invite_code"` + UserName string `xorm:"user_name"` + UserLevel int `xorm:"user_level"` + UserDesc string `xorm:"user_desc"` + EnergyMax int `xorm:"energy_max"` + AwardID int `xorm:"award_id"` + LastLoginTime int64 `xorm:"last_login_time"` + CenterUnitOwningUserID int `xorm:"center_unit_owning_user_id"` + NaviUnitOwningUserID int `xorm:"navi_unit_owning_user_id"` +} + +func search(ctx *gin.Context) { + ss := session.Get(ctx) + defer ss.Finalize() + + req := friendschema.SearchReq{} + err := honokautils.ParseRequestData(ctx, &req) + if ss.CheckErr(err) { + return + } + + targetUserID, err := resolveUserIDByInviteCode(ss, req.InviteCode) + if ss.CheckErr(err) { + return + } + + row := friendSearchRow{} + has, err := ss.UserEng.Table(new(usermodel.UserPref)).Alias("up"). + Join("LEFT", "users u", "u.user_id = up.user_id"). + Join("LEFT", "user_deck ud", "ud.user_id = up.user_id AND ud.main_flag = 1"). + Join("LEFT", "user_deck_unit udu", "udu.user_deck_id = ud.id AND udu.position = 5"). + Select(` + up.user_id, + up.invite_code, + up.user_name, + up.user_level, + up.user_desc, + up.energy_max, + up.award_id, + u.last_login_time, + COALESCE(udu.unit_owning_user_id, up.unit_owning_user_id) AS center_unit_owning_user_id, + up.unit_owning_user_id AS navi_unit_owning_user_id + `). + Where("up.user_id = ?", targetUserID). + Get(&row) + if ss.CheckErr(err) { + return + } + if !has { + ss.CheckErr(errors.New("user not found")) + return + } + + unitCount, err := ss.UserEng.Table(new(usermodel.UserUnitData)). + Where("user_id = ?", targetUserID). + Count() + if ss.CheckErr(err) { + return + } + + centerUnitInfo, err := buildCenterUnitInfo(ss, targetUserID, row.CenterUnitOwningUserID, row.AwardID) + if ss.CheckErr(err) { + return + } + + costume, err := getSearchCostume(ss, targetUserID, row.NaviUnitOwningUserID, centerUnitInfo) + if ss.CheckErr(err) { + return + } + + ss.Respond(friendschema.SearchResp{ + ResponseData: friendschema.SearchData{ + UserInfo: friendschema.SearchUserInfo{ + UserID: row.UserID, + Name: row.UserName, + Level: row.UserLevel, + CostMax: 100, + UnitMax: 5000, + EnergyMax: positiveIntOrDefault(row.EnergyMax, usermodel.DefaultUserEnergyMax), + FriendMax: 99, + UnitCnt: int(unitCount), + ElapsedTimeFromLogin: formatElapsedTime(row.LastLoginTime), + Comment: row.UserDesc, + }, + CenterUnitInfo: toSearchCenterUnitInfo(centerUnitInfo, costume), + SettingAwardID: row.AwardID, + IsAlliance: false, + FriendStatus: 0, + ServerTimestamp: time.Now().Unix(), + }, + ReleaseInfo: []any{}, + StatusCode: 200, + }) +} + +func init() { + router.AddHandler("main.php", "POST", "/friend/search", middleware.Common, search) +} + +func positiveIntOrDefault(value, fallback int) int { + if value > 0 { + return value + } + return fallback +} + +func toSearchCenterUnitInfo(info friendschema.CenterUnitInfo, costume friendschema.SearchCostume) friendschema.SearchCenterUnitInfo { + return friendschema.SearchCenterUnitInfo{ + UnitOwningUserID: info.UnitOwningUserID, + UnitID: info.UnitID, + Exp: info.Exp, + NextExp: info.NextExp, + Level: info.Level, + LevelLimitID: info.LevelLimitID, + MaxLevel: info.MaxLevel, + Rank: info.Rank, + MaxRank: info.MaxRank, + Love: info.Love, + MaxLove: info.MaxLove, + UnitSkillLevel: info.UnitSkillLevel, + MaxHp: info.MaxHp, + FavoriteFlag: info.FavoriteFlag, + DisplayRank: info.DisplayRank, + UnitSkillExp: info.UnitSkillExp, + UnitRemovableSkillCapacity: info.UnitRemovableSkillCapacity, + Attribute: info.Attribute, + Smile: info.Smile, + Cute: info.Cute, + Cool: info.Cool, + IsLoveMax: info.IsLoveMax, + IsLevelMax: info.IsLevelMax, + IsRankMax: info.IsRankMax, + IsSigned: info.IsSigned, + IsSkillLevelMax: info.IsSkillLevelMax, + SettingAwardID: info.SettingAwardID, + RemovableSkillIds: info.RemovableSkillIds, + AccessoryInfo: info.AccessoryInfo, + Costume: costume, + } +} + +func getSearchCostume(ss *session.Session, targetUserID, unitOwningUserID int, fallback friendschema.CenterUnitInfo) (friendschema.SearchCostume, error) { + costume := friendschema.SearchCostume{ + UnitID: fallback.UnitID, + IsRankMax: fallback.IsRankMax, + IsSigned: fallback.IsSigned, + } + if unitOwningUserID <= 0 { + return costume, 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 costume, err + } + if !has { + return costume, nil + } + + return friendschema.SearchCostume{ + UnitID: unitData.UnitID, + IsRankMax: unitData.IsRankMax, + IsSigned: unitData.IsSigned, + }, nil +} diff --git a/internal/handler/ghome/account/login.go b/internal/handler/ghome/account/login.go index b3d0e98..219cb1d 100644 --- a/internal/handler/ghome/account/login.go +++ b/internal/handler/ghome/account/login.go @@ -362,6 +362,16 @@ func addUser(dbSession *xorm.Session, phone, password string, isDefault bool) (g } } + if !isDefault { + err = usermodel.EnsureDefaultFriendship(dbSession, userID) + if err != nil { + if localSession { + dbSession.Rollback() + } + return loginData, loginCode, loginMsg, created, err + } + } + loginData.Autokey = autoKey loginData.HasRealInfo = 1 loginData.Message = "ok" diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 1a50a5c..e77ab71 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -8,6 +8,7 @@ import ( _ "honoka-chan/internal/handler/background" _ "honoka-chan/internal/handler/download" _ "honoka-chan/internal/handler/event" + _ "honoka-chan/internal/handler/friend" _ "honoka-chan/internal/handler/gdpr" _ "honoka-chan/internal/handler/ghome" _ "honoka-chan/internal/handler/lbonus" diff --git a/internal/model/user/userfriend.go b/internal/model/user/userfriend.go new file mode 100644 index 0000000..7e2b449 --- /dev/null +++ b/internal/model/user/userfriend.go @@ -0,0 +1,107 @@ +package usermodel + +import ( + "time" + + "xorm.io/xorm" +) + +const ( + DefaultSystemPhone = "1" + + FriendStatusApproved = iota + FriendStatusPending + FriendStatusAwaitingApproval + + FriendListPageSize = 40 +) + +type UserFriend struct { + ID int `xorm:"id pk autoincr"` + UserID int `xorm:"user_id unique(friend_pair) index"` + FriendUserID int `xorm:"friend_user_id unique(friend_pair) index"` + Status int `xorm:"status index"` + IsNew bool `xorm:"is_new"` + InsertDate int64 `xorm:"insert_date index"` + UpdateDate int64 `xorm:"update_date"` +} + +func (UserFriend) TableName() string { + return "user_friend" +} + +func EnsureFriendLink(dbSession *xorm.Session, userID, friendUserID, status int, isNew bool) error { + if dbSession == nil || userID <= 0 || friendUserID <= 0 || userID == friendUserID { + return nil + } + + now := time.Now().Unix() + link := UserFriend{} + has, err := dbSession.Table(new(UserFriend)). + Where("user_id = ? AND friend_user_id = ?", userID, friendUserID). + Get(&link) + if err != nil { + return err + } + + if has { + update := UserFriend{ + Status: status, + IsNew: isNew, + UpdateDate: now, + } + if link.InsertDate <= 0 { + update.InsertDate = now + } + + _, err = dbSession.Table(new(UserFriend)). + Where("user_id = ? AND friend_user_id = ?", userID, friendUserID). + Cols("status", "is_new", "update_date", "insert_date"). + Update(&update) + return err + } + + _, err = dbSession.Table(new(UserFriend)).Insert(&UserFriend{ + UserID: userID, + FriendUserID: friendUserID, + Status: status, + IsNew: isNew, + InsertDate: now, + UpdateDate: now, + }) + return err +} + +func EnsureMutualFriend(dbSession *xorm.Session, userID, friendUserID, status int) error { + if err := EnsureFriendLink(dbSession, userID, friendUserID, status, false); err != nil { + return err + } + return EnsureFriendLink(dbSession, friendUserID, userID, status, false) +} + +func EnsureMutualFriendWithIsNew(dbSession *xorm.Session, userID, friendUserID, status int, userIsNew, friendIsNew bool) error { + if err := EnsureFriendLink(dbSession, userID, friendUserID, status, userIsNew); err != nil { + return err + } + return EnsureFriendLink(dbSession, friendUserID, userID, status, friendIsNew) +} + +func EnsureDefaultFriendship(dbSession *xorm.Session, userID int) error { + if dbSession == nil || userID <= 0 { + return nil + } + + defaultUser := Users{} + has, err := dbSession.Table(new(Users)). + Where("phone = ?", DefaultSystemPhone). + Cols("user_id"). + Get(&defaultUser) + if err != nil { + return err + } + if !has || defaultUser.UserID <= 0 || defaultUser.UserID == userID { + return nil + } + + return EnsureMutualFriend(dbSession, userID, defaultUser.UserID, FriendStatusApproved) +} diff --git a/internal/schema/api/api.go b/internal/schema/api/api.go index cef60f5..5e5056c 100644 --- a/internal/schema/api/api.go +++ b/internal/schema/api/api.go @@ -2,6 +2,7 @@ package apischema type ApiReq struct { Module string `json:"module"` + UserID int `json:"user_id"` Action string `json:"action"` Timestamp int64 `json:"timeStamp"` } diff --git a/internal/schema/api/profile/info.go b/internal/schema/api/profile/info.go index f6c5dfb..4332cf8 100644 --- a/internal/schema/api/profile/info.go +++ b/internal/schema/api/profile/info.go @@ -60,40 +60,40 @@ type UserInfo struct { } type CenterUnitInfo struct { - UnitOwningUserID int `json:"unit_owning_user_id"` - UnitID int `json:"unit_id"` - Exp int `json:"exp"` - NextExp int `json:"next_exp"` - Level int `json:"level"` - LevelLimitID int `json:"level_limit_id"` - MaxLevel int `json:"max_level"` - Rank int `json:"rank"` - MaxRank int `json:"max_rank"` - Love int `json:"love"` - MaxLove int `json:"max_love"` - UnitSkillLevel int `json:"unit_skill_level"` - MaxHp int `json:"max_hp"` - FavoriteFlag bool `json:"favorite_flag"` - DisplayRank int `json:"display_rank"` - UnitSkillExp int `json:"unit_skill_exp"` - UnitRemovableSkillCapacity int `json:"unit_removable_skill_capacity"` - Attribute int `json:"attribute"` - Smile int `json:"smile"` - Cute int `json:"cute"` - Cool int `json:"cool"` - IsLoveMax bool `json:"is_love_max"` - IsLevelMax bool `json:"is_level_max"` - IsRankMax bool `json:"is_rank_max"` - IsSigned bool `json:"is_signed"` - IsSkillLevelMax bool `json:"is_skill_level_max"` - SettingAwardID int `json:"setting_award_id"` - RemovableSkillIds []int `json:"removable_skill_ids"` - AccessoryInfo AccessoryInfo `json:"accessory_info"` - Costume Costume `json:"costume"` - TotalSmile int `json:"total_smile"` - TotalCute int `json:"total_cute"` - TotalCool int `json:"total_cool"` - TotalHp int `json:"total_hp"` + UnitOwningUserID int `json:"unit_owning_user_id"` + UnitID int `json:"unit_id"` + Exp int `json:"exp"` + NextExp int `json:"next_exp"` + Level int `json:"level"` + LevelLimitID int `json:"level_limit_id"` + MaxLevel int `json:"max_level"` + Rank int `json:"rank"` + MaxRank int `json:"max_rank"` + Love int `json:"love"` + MaxLove int `json:"max_love"` + UnitSkillLevel int `json:"unit_skill_level"` + MaxHp int `json:"max_hp"` + FavoriteFlag bool `json:"favorite_flag"` + DisplayRank int `json:"display_rank"` + UnitSkillExp int `json:"unit_skill_exp"` + UnitRemovableSkillCapacity int `json:"unit_removable_skill_capacity"` + Attribute int `json:"attribute"` + Smile int `json:"smile"` + Cute int `json:"cute"` + Cool int `json:"cool"` + IsLoveMax bool `json:"is_love_max"` + IsLevelMax bool `json:"is_level_max"` + IsRankMax bool `json:"is_rank_max"` + IsSigned bool `json:"is_signed"` + IsSkillLevelMax bool `json:"is_skill_level_max"` + SettingAwardID int `json:"setting_award_id"` + RemovableSkillIds []int `json:"removable_skill_ids"` + AccessoryInfo *AccessoryInfo `json:"accessory_info,omitempty"` + Costume Costume `json:"costume"` + TotalSmile int `json:"total_smile"` + TotalCute int `json:"total_cute"` + TotalCool int `json:"total_cool"` + TotalHp int `json:"total_hp"` } type NaviUnitInfo struct { diff --git a/internal/schema/friend/common.go b/internal/schema/friend/common.go new file mode 100644 index 0000000..2446059 --- /dev/null +++ b/internal/schema/friend/common.go @@ -0,0 +1,10 @@ +package friendschema + +type UserIDReq struct { + UserID int `json:"user_id"` +} + +type ResponseReq struct { + UserID int `json:"user_id"` + Status int `json:"status"` +} diff --git a/internal/schema/friend/expel.go b/internal/schema/friend/expel.go new file mode 100644 index 0000000..8a64273 --- /dev/null +++ b/internal/schema/friend/expel.go @@ -0,0 +1,7 @@ +package friendschema + +type ExpelResp struct { + ResponseData []any `json:"response_data"` + ReleaseInfo []any `json:"release_info"` + StatusCode int `json:"status_code"` +} diff --git a/internal/schema/friend/list.go b/internal/schema/friend/list.go new file mode 100644 index 0000000..716de77 --- /dev/null +++ b/internal/schema/friend/list.go @@ -0,0 +1,84 @@ +package friendschema + +type ListReq struct { + Module string `json:"module"` + Type int `json:"type"` + Action string `json:"action"` + TimeStamp int64 `json:"timeStamp"` + Mgd int `json:"mgd"` + Sort int `json:"sort"` + Page int `json:"page"` + CommandNum string `json:"commandNum"` +} + +type UserData struct { + UserID int `json:"user_id"` + Name string `json:"name"` + Level int `json:"level"` + ElapsedTimeFromLogin string `json:"elapsed_time_from_login"` + ElapsedTimeFromApplied string `json:"elapsed_time_from_applied"` + Comment string `json:"comment"` +} + +type AccessoryInfo struct { + AccessoryOwningUserID int `json:"accessory_owning_user_id"` + AccessoryID int `json:"accessory_id"` + Exp int `json:"exp"` + NextExp int `json:"next_exp"` + Level int `json:"level"` + MaxLevel int `json:"max_level"` + RankUpCount int `json:"rank_up_count"` + FavoriteFlag bool `json:"favorite_flag"` +} + +type CenterUnitInfo struct { + UnitOwningUserID int64 `json:"unit_owning_user_id"` + UnitID int `json:"unit_id"` + Exp int `json:"exp"` + NextExp int `json:"next_exp"` + Level int `json:"level"` + LevelLimitID int `json:"level_limit_id"` + MaxLevel int `json:"max_level"` + Rank int `json:"rank"` + MaxRank int `json:"max_rank"` + Love int `json:"love"` + MaxLove int `json:"max_love"` + UnitSkillLevel int `json:"unit_skill_level"` + MaxHp int `json:"max_hp"` + FavoriteFlag bool `json:"favorite_flag"` + DisplayRank int `json:"display_rank"` + UnitSkillExp int `json:"unit_skill_exp"` + UnitRemovableSkillCapacity int `json:"unit_removable_skill_capacity"` + Attribute int `json:"attribute"` + Smile int `json:"smile"` + Cute int `json:"cute"` + Cool int `json:"cool"` + IsLoveMax bool `json:"is_love_max"` + IsLevelMax bool `json:"is_level_max"` + IsRankMax bool `json:"is_rank_max"` + IsSigned bool `json:"is_signed"` + IsSkillLevelMax bool `json:"is_skill_level_max"` + SettingAwardID int `json:"setting_award_id"` + RemovableSkillIds []int `json:"removable_skill_ids"` + AccessoryInfo AccessoryInfo `json:"accessory_info"` +} + +type FriendList struct { + UserData UserData `json:"user_data"` + CenterUnitInfo CenterUnitInfo `json:"center_unit_info"` + SettingAwardID int `json:"setting_award_id"` + IsNew bool `json:"is_new"` +} + +type ListData struct { + ItemCount int `json:"item_count"` + FriendList []FriendList `json:"friend_list"` + NewFriendList []any `json:"new_friend_list"` + ServerTimestamp int64 `json:"server_timestamp"` +} + +type ListResp struct { + ResponseData ListData `json:"response_data"` + ReleaseInfo []any `json:"release_info"` + StatusCode int `json:"status_code"` +} diff --git a/internal/schema/friend/request.go b/internal/schema/friend/request.go new file mode 100644 index 0000000..f6fdc78 --- /dev/null +++ b/internal/schema/friend/request.go @@ -0,0 +1,11 @@ +package friendschema + +type RequestData struct { + IsFriend bool `json:"is_friend"` +} + +type RequestResp struct { + ResponseData RequestData `json:"response_data"` + ReleaseInfo []any `json:"release_info"` + StatusCode int `json:"status_code"` +} diff --git a/internal/schema/friend/requestcancel.go b/internal/schema/friend/requestcancel.go new file mode 100644 index 0000000..e6ebbd5 --- /dev/null +++ b/internal/schema/friend/requestcancel.go @@ -0,0 +1,11 @@ +package friendschema + +type RequestCancelData struct { + IsFriend bool `json:"is_friend"` +} + +type RequestCancelResp struct { + ResponseData RequestCancelData `json:"response_data"` + ReleaseInfo []any `json:"release_info"` + StatusCode int `json:"status_code"` +} diff --git a/internal/schema/friend/response.go b/internal/schema/friend/response.go new file mode 100644 index 0000000..35daff6 --- /dev/null +++ b/internal/schema/friend/response.go @@ -0,0 +1,7 @@ +package friendschema + +type ResponseResp struct { + ResponseData []any `json:"response_data"` + ReleaseInfo []any `json:"release_info"` + StatusCode int `json:"status_code"` +} diff --git a/internal/schema/friend/search.go b/internal/schema/friend/search.go new file mode 100644 index 0000000..6cec8f6 --- /dev/null +++ b/internal/schema/friend/search.go @@ -0,0 +1,77 @@ +package friendschema + +type SearchReq struct { + Module string `json:"module"` + Action string `json:"action"` + TimeStamp int64 `json:"timeStamp"` + Mgd int `json:"mgd"` + InviteCode string `json:"invite_code"` + CommandNum string `json:"commandNum"` +} + +type SearchUserInfo struct { + UserID int `json:"user_id"` + Name string `json:"name"` + Level int `json:"level"` + CostMax int `json:"cost_max"` + UnitMax int `json:"unit_max"` + EnergyMax int `json:"energy_max"` + FriendMax int `json:"friend_max"` + UnitCnt int `json:"unit_cnt"` + ElapsedTimeFromLogin string `json:"elapsed_time_from_login"` + Comment string `json:"comment"` +} + +type SearchCostume struct { + UnitID int `json:"unit_id"` + IsRankMax bool `json:"is_rank_max"` + IsSigned bool `json:"is_signed"` +} + +type SearchCenterUnitInfo struct { + UnitOwningUserID int64 `json:"unit_owning_user_id"` + UnitID int `json:"unit_id"` + Exp int `json:"exp"` + NextExp int `json:"next_exp"` + Level int `json:"level"` + LevelLimitID int `json:"level_limit_id"` + MaxLevel int `json:"max_level"` + Rank int `json:"rank"` + MaxRank int `json:"max_rank"` + Love int `json:"love"` + MaxLove int `json:"max_love"` + UnitSkillLevel int `json:"unit_skill_level"` + MaxHp int `json:"max_hp"` + FavoriteFlag bool `json:"favorite_flag"` + DisplayRank int `json:"display_rank"` + UnitSkillExp int `json:"unit_skill_exp"` + UnitRemovableSkillCapacity int `json:"unit_removable_skill_capacity"` + Attribute int `json:"attribute"` + Smile int `json:"smile"` + Cute int `json:"cute"` + Cool int `json:"cool"` + IsLoveMax bool `json:"is_love_max"` + IsLevelMax bool `json:"is_level_max"` + IsRankMax bool `json:"is_rank_max"` + IsSigned bool `json:"is_signed"` + IsSkillLevelMax bool `json:"is_skill_level_max"` + SettingAwardID int `json:"setting_award_id"` + RemovableSkillIds []int `json:"removable_skill_ids"` + AccessoryInfo AccessoryInfo `json:"accessory_info"` + Costume SearchCostume `json:"costume"` +} + +type SearchData struct { + UserInfo SearchUserInfo `json:"user_info"` + CenterUnitInfo SearchCenterUnitInfo `json:"center_unit_info"` + SettingAwardID int `json:"setting_award_id"` + IsAlliance bool `json:"is_alliance"` + FriendStatus int `json:"friend_status"` + ServerTimestamp int64 `json:"server_timestamp"` +} + +type SearchResp struct { + ResponseData SearchData `json:"response_data"` + ReleaseInfo []any `json:"release_info"` + StatusCode int `json:"status_code"` +} diff --git a/internal/startup/database.go b/internal/startup/database.go index 090b59f..3a22078 100644 --- a/internal/startup/database.go +++ b/internal/startup/database.go @@ -27,6 +27,7 @@ func CreateTables() { db.UserEng.Sync2(new(usermodel.UserLiveGoal)) db.UserEng.Sync2(new(usermodel.UserLiveInProgress)) db.UserEng.Sync2(new(usermodel.UserLiveRecord)) + db.UserEng.Sync2(new(usermodel.UserFriend)) db.UserEng.Sync2(new(usermodel.UserPref)) db.UserEng.Sync2(new(usermodel.Users)) db.UserEng.Sync2(new(usermodel.UserUnit)) diff --git a/internal/startup/defaultuser.go b/internal/startup/defaultuser.go index d43d843..2bf0b01 100644 --- a/internal/startup/defaultuser.go +++ b/internal/startup/defaultuser.go @@ -2,16 +2,16 @@ package startup import ( "honoka-chan/internal/handler/ghome/account" + usermodel "honoka-chan/internal/model/user" "log" ) const ( - defaultPhone = "1" defaultPassword = "klsbgames" ) func CreateDefaultUser() { - _, code, msg, created, err := account.AddUser(defaultPhone, defaultPassword, true) + _, code, msg, created, err := account.AddUser(usermodel.DefaultSystemPhone, defaultPassword, true) if err != nil { log.Fatalln("默认用户创建失败:", err.Error()) } @@ -19,7 +19,7 @@ func CreateDefaultUser() { log.Fatalf("默认用户创建失败: code=%d msg=%s", code, msg) } if created { - log.Printf("默认用户创建成功, 账号: %s, 密码: %s\n", defaultPhone, defaultPassword) + log.Printf("默认用户创建成功, 账号: %s, 密码: %s\n", usermodel.DefaultSystemPhone, defaultPassword) return } } diff --git a/internal/startup/friend.go b/internal/startup/friend.go new file mode 100644 index 0000000..5267523 --- /dev/null +++ b/internal/startup/friend.go @@ -0,0 +1,38 @@ +package startup + +import ( + usermodel "honoka-chan/internal/model/user" + "honoka-chan/pkg/db" + "log" +) + +func EnsureDefaultFriends() { + session := db.UserEng.NewSession() + defer session.Close() + + if err := session.Begin(); err != nil { + log.Fatalln("初始化默认好友失败:", err.Error()) + } + + userIDs := []int{} + err := session.Table(new(usermodel.Users)). + Cols("user_id"). + Find(&userIDs) + if err != nil { + session.Rollback() + log.Fatalln("初始化默认好友失败:", err.Error()) + } + + for _, userID := range userIDs { + err = usermodel.EnsureDefaultFriendship(session, userID) + if err != nil { + session.Rollback() + log.Fatalln("初始化默认好友失败:", err.Error()) + } + } + + if err := session.Commit(); err != nil { + session.Rollback() + log.Fatalln("初始化默认好友失败:", err.Error()) + } +} diff --git a/internal/startup/startup.go b/internal/startup/startup.go index 3f44882..a247f0c 100644 --- a/internal/startup/startup.go +++ b/internal/startup/startup.go @@ -4,4 +4,5 @@ func StartUp() { CreateTables() LoadUnitData() CreateDefaultUser() + EnsureDefaultFriends() }