From 67b095724bcc2d5563f3580e6dfd33240eaee08c Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Tue, 20 Oct 2026 09:25:34 +0800 Subject: [PATCH] fix: allow reapplication after withdrawal with isolated history and authorization --- .../blackbox.py | 150 ++++++++++++++++++ .../verify_contract.py | 25 +++ ...ication-reapplication-handover-20261020.md | 67 ++++++++ docs/openapi-rc.json | 20 ++- .../mydreamjob/application/Application.java | 18 ++- .../application/ApplicationController.java | 4 +- .../application/ApplicationRepository.java | 12 +- .../application/ApplicationService.java | 22 ++- .../application/dto/CandidateDetailView.java | 4 +- .../application/dto/CandidateItemView.java | 4 +- .../ApplicationMatchRecordRepository.java | 6 + .../MatchConversationService.java | 3 + .../com/mydreamjob/job/JobRepository.java | 4 + .../java/com/mydreamjob/job/JobService.java | 10 +- .../messaging/MessagingService.java | 20 +++ .../V58__application_reapplication.sql | 12 ++ ...plicationReapplicationIntegrationTest.java | 3 + ...ApplicationReapplicationMigrationTest.java | 55 +++++++ ...tionReapplicationMySqlIntegrationTest.java | 36 +++++ .../ApplicationReapplicationTestBase.java | 111 +++++++++++++ .../application/ApplicationServiceTest.java | 18 +-- ...ompanyDisplayFieldsMysqlMigrationTest.java | 6 +- 22 files changed, 580 insertions(+), 30 deletions(-) create mode 100644 docs/acceptance/application-reapplication-20261020/blackbox.py create mode 100644 docs/acceptance/application-reapplication-20261020/verify_contract.py create mode 100644 docs/application-reapplication-handover-20261020.md create mode 100644 mydreamjob-server/src/main/resources/db/migration/V58__application_reapplication.sql create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMigrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMySqlIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationTestBase.java diff --git a/docs/acceptance/application-reapplication-20261020/blackbox.py b/docs/acceptance/application-reapplication-20261020/blackbox.py new file mode 100644 index 0000000..c2375e4 --- /dev/null +++ b/docs/acceptance/application-reapplication-20261020/blackbox.py @@ -0,0 +1,150 @@ +"""重投 JAR 黑盒,仅固定本地测试容器/库。无生产目标参数,无真实短信。""" +import argparse,concurrent.futures,datetime,hashlib,json,os,pathlib,socket,subprocess,time,uuid +import requests,websocket + +ROOT=pathlib.Path('D:/Workspaces/qsp-reapply-20261020') +API='http://127.0.0.1:18090/api/v1' +DB='qsp_reapply_1020' +PASSWORD='qsp-reapply-isolated-only' +NAMES=['qsp-reapply-1020-mysql','qsp-reapply-1020-redis','qsp-reapply-1020-rabbit'] +RESULTS=[] +def save(name,data): + (ROOT/name).write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding='utf-8') +def inspect(name):return json.loads(subprocess.check_output(['docker','inspect',name]))[0] +def sql(text): + i=inspect(NAMES[0]);assert i['Name']=='/'+NAMES[0] and i['HostConfig']['PortBindings']['3306/tcp'][0]['HostIp']=='127.0.0.1' + p=subprocess.run(['docker','exec','-i',NAMES[0],'mysql','-uroot','-p'+PASSWORD,'--default-character-set=utf8mb4','-NB',DB],input=text,capture_output=True,text=True,encoding='utf-8') + if p.returncode:raise RuntimeError(p.stderr[:400]) + return [line.split('\t') for line in p.stdout.strip().splitlines() if line] +def check(name,ok,detail=None): + RESULTS.append({'case':name,'ok':bool(ok),'detail':detail});save('blackbox-results.json',RESULTS);print(name,bool(ok),flush=True) + if not ok:raise AssertionError(name) +def http(method,path,token=None,body=None,files=None): + return requests.request(method,API+path,headers={'Authorization':'Bearer '+token} if token else {},json=body,files=files,timeout=30) +def good(name,method,path,token=None,body=None): + r=http(method,path,token,body);j=r.json();check(name,r.status_code==200,{'status':r.status_code,'response':j});return j['data'] +def denied(name,method,path,token,body,code): + expected={7001:404,8001:409,8303:403,8304:409,8309:409,8106:400,2002:403}[code] + r=http(method,path,token,body);j=r.json();check(name,r.status_code==expected and j.get('code')==code,{'expectedStatus':expected,'status':r.status_code,'response':j}) +def login(phone): + sql("INSERT INTO sms_verification_codes(phone,code,purpose,attempts,max_attempts,expired_at,used,created_at) VALUES('%s','246810','login',0,5,DATE_ADD(NOW(),INTERVAL 1 DAY),0,NOW())"%phone) + r=http('POST','/auth/login',body={'phone':phone,'code':'246810','purpose':'login'}) + if r.status_code!=200:raise RuntimeError('Isolated login failed') + return r.json()['data']['accessToken'] +def setup(): + for port in [3309,16390,15690,18090,18091]: + with socket.socket() as s: + if s.connect_ex(('127.0.0.1',port))==0:raise RuntimeError('Occupied port '+str(port)) + for name in NAMES: + if subprocess.run(['docker','inspect',name],capture_output=True).returncode==0:raise RuntimeError('Existing container: '+name) + state={'containers':{},'volumes':[]} + save('environment.json',state) + for name,image,port,internal,memory,extra in [ + (NAMES[0],'mysql:8.4',3309,3306,'768m',['-e','MYSQL_ROOT_PASSWORD='+PASSWORD,'-e','MYSQL_DATABASE='+DB]), + (NAMES[1],'redis:7.4-alpine',16390,6379,'128m',[]), + (NAMES[2],'rabbitmq:3.13-alpine',15690,5672,'256m',[])]: + cmd=['docker','run','-d','--name',name,'--label','qsp.acceptance=reapplication-20261020','--memory='+memory,'-p',f'127.0.0.1:{port}:{internal}']+extra+[image] + cid=subprocess.check_output(cmd,text=True).strip();state['containers'][name]=cid + state['volumes'].extend(m['Name'] for m in inspect(name)['Mounts'] if m['Type']=='volume');save('environment.json',state) + for _ in range(60): + try:sql('SELECT 1');return + except Exception:time.sleep(1) + raise RuntimeError('MySQL readiness timeout') +def start(): + jar=ROOT/'mydreamjob-server/target/mydreamjob-server-0.0.1-SNAPSHOT.jar' + for name in ['logs','tmp','storage','resume-exports','data-exports']:(ROOT/name).mkdir(exist_ok=True) + env=os.environ.copy();env.update(DB_URL=f'jdbc:mysql://127.0.0.1:3309/{DB}?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai',DB_USERNAME='root',DB_PASSWORD=PASSWORD, + REDIS_HOST='127.0.0.1',REDIS_PORT='16390',REDIS_PASSWORD='',RABBITMQ_HOST='127.0.0.1',RABBITMQ_PORT='15690',RABBITMQ_USERNAME='guest',RABBITMQ_PASSWORD='guest', + JWT_ACCESS_SECRET='reapply-isolated-access-secret-no-production-0001',JWT_REFRESH_SECRET='reapply-isolated-refresh-secret-no-production-001',JWT_TOKEN_HASH_SECRET='reapply-isolated-tokenhash-secret-no-production-01', + SMS_ALIYUN_ENABLED='false',SMS_MOCK_ENABLED='true',ADMIN_BOOTSTRAP_ENABLED='false',RESUME_STORAGE_DIR=str(ROOT/'storage'),RESUME_EXPORT_DIR=str(ROOT/'resume-exports'),DATA_EXPORT_DIR=str(ROOT/'data-exports'),TEMP=str(ROOT/'tmp'),TMP=str(ROOT/'tmp')) + cmd=['java','-Xms128m','-Xmx512m','-Djava.io.tmpdir='+str(ROOT/'tmp'),'-jar',str(jar),'--server.address=127.0.0.1','--server.port=18090','--management.server.port=18091', + '--app.offer.scheduler.enabled=false','--app.match.notification.enabled=false','--app.notification.mq-enabled=false','--app.risk.scan.enabled=false','--app.scoring.refresh.enabled=false','--app.scoring.refresh.compensation-enabled=false','--spring.rabbitmq.listener.simple.auto-startup=false'] + p=subprocess.Popen(cmd,env=env,cwd=ROOT,stdout=open(ROOT/'logs/blackbox-app.log','ab'),stderr=open(ROOT/'logs/blackbox-app.err.log','ab'),creationflags=subprocess.CREATE_NO_WINDOW) + save('process.json',{'pid':p.pid,'jar':str(jar),'sha256':hashlib.sha256(jar.read_bytes()).hexdigest()}) + for _ in range(60): + if p.poll() is not None:raise RuntimeError('App exited') + try: + if requests.get('http://127.0.0.1:18091/actuator/health',timeout=2).status_code==200:print('APP_READY',p.pid);return + except requests.RequestException:pass + time.sleep(1) + raise RuntimeError('App health timeout') +def run(): + stamp=uuid.uuid4().hex[:8];suffix=uuid.uuid4().int%999999 + phones=['19908'+str(suffix+i).zfill(6) for i in range(2)] + for phone,role in zip(phones,['STUDENT','COMPANY']): + sql("INSERT INTO users(phone,nickname,role,is_active,is_verified) VALUES('%s','synthetic-%s','%s',1,1)"%(phone,stamp,role)) + sql("INSERT INTO user_roles(user_id,role) SELECT id,'%s' FROM users WHERE phone='%s'"%(role,phone)) + student=int(sql("SELECT id FROM users WHERE phone='%s'"%phones[0])[0][0]);hr=int(sql("SELECT id FROM users WHERE phone='%s'"%phones[1])[0][0]) + sql("INSERT INTO companies(owner_user_id,name,verified) VALUES(%d,'synthetic-reapply',1)"%hr) + ta,th=map(login,phones) + pdf=pathlib.Path('D:/Acceptance/qsp-filter-full-20261019-1/continuation-1/synthetic-resume.pdf').read_bytes() + r=http('POST','/profile/me/resume',ta,files={'file':('synthetic.pdf',pdf,'application/pdf')});check('real PDF upload',r.status_code==200) + good('privacy','PUT','/profile/me/privacy',ta,{'resumeVisibility':'APPLIED_COMPANIES','contactVisible':False}) + job=good('create job','POST','/jobs',th,{'title':'synthetic-reapply','description':'not real recruitment','city':'TEST','employmentType':'FULL_TIME'})['id'] + good('publish job','PATCH',f'/jobs/{job}/status',th,{'status':'PUBLISHED'}) + def apply():return http('POST','/applications',ta,{'jobId':job}) + def match(app): + good('candidate scores new round','PUT',f'/applications/{app}/candidate-interest-score',ta,{'score':90}) + d=good('HR scores new round','PUT',f'/applications/{app}/hr-suitability-score',th,{'score':90});check('new round matched',d['status']=='MATCHED') + return good('matched conversation','POST',f'/applications/{app}/conversation',ta)['id'] + a=apply().json()['data'];check('first round',a['attemptNumber']==1) + conv=match(a['id']);oldmsg=good('old resume card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':a['id'],'clientMessageId':'old-'+stamp})['id'] + future=datetime.datetime.now()+datetime.timedelta(days=2) + interview=good('old interview fixture','POST',f"/applications/{a['id']}/interviews",th,{'title':'synthetic old interview','interviewType':'ONLINE','startAt':future.isoformat(),'endAt':(future+datetime.timedelta(hours=1)).isoformat(),'meetingUrl':'https://example.invalid/interview'}) + offer=good('old draft offer fixture','POST','/offers',th,{'applicationId':a['id'],'title':'synthetic old offer','description':'not real employment','workCity':'TEST','expireAt':future.isoformat()}) + oldurl=f'/conversations/{conv}/messages/{oldmsg}/resume' + good('withdraw first round','POST',f"/applications/{a['id']}/withdraw",ta) + denied('old card denied after withdrawal','GET',oldurl,th,None,8309) + denied('old company download denied','GET',f"/company/candidates/{a['id']}/resume",th,None,2002) + denied('old application cannot unlock','POST',f"/applications/{a['id']}/conversation",ta,None,8304) + denied('historical send locked','POST','/conversations/messages',ta,{'conversationId':conv,'content':'blocked'},8303) + good('history readable','GET',f'/conversations/{conv}/messages',ta) + b=apply().json()['data'];check('second id and lineage',b['id']!=a['id'] and b['attemptNumber']==2 and b['previousApplicationId']==a['id'],b) + d=good('new round interview list','GET',f"/applications/{b['id']}/interviews",th);check('interviews not copied',d==[],d) + d=good('new round offer list','GET',f"/offers?applicationId={b['id']}",th);check('offers not copied',d['total']==0,d) + denied('old offer cannot be sent after withdrawal','PATCH',f"/offers/{offer['id']}/status",th,{'status':'SENT'},8106) + denied('new round not auto-matched','POST',f"/applications/{b['id']}/conversation",ta,None,8304) + denied('new round cannot send inherited card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':b['id'],'clientMessageId':'pending-'+stamp},8303) + good('withdraw second round','POST',f"/applications/{b['id']}/withdraw",ta) + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:responses=list(pool.map(lambda _:apply(),range(4))) + wins=[r for r in responses if r.status_code==200];losers=[r for r in responses if r.status_code!=200] + check('concurrent one winner three business duplicates',len(wins)==1 and len(losers)==3 and all(r.json().get('code')==8001 for r in losers),[{'status':r.status_code,'data':r.json()} for r in responses]) + c=wins[0].json()['data'];check('third round lineage',c['attemptNumber']==3 and c['previousApplicationId']==b['id'],c) + newpdf=pdf+b'\n% new-round-current-resume\n';r=http('POST','/profile/me/resume',ta,files={'file':('current.pdf',newpdf,'application/pdf')});check('replace source before rematch',r.status_code==200) + check('rematch reuses historical chat',match(c['id'])==conv) + ws=websocket.create_connection('ws://127.0.0.1:18090/api/v1/ws',origin='http://localhost:3000',subprotocols=['v12.stomp'],timeout=10,http_proxy_host=None) + ws.send('CONNECT\naccept-version:1.2\nheart-beat:0,0\nAuthorization:Bearer '+th+'\n\n\x00');check('STOMP after rematch authenticated',ws.recv().startswith('CONNECTED')) + ws.send('SUBSCRIBE\nid:reapply\ndestination:/user/queue/chat-events\n\n\x00');check('STOMP subscription ready','CHAT_READY' in ws.recv()) + text=good('send after fresh match','POST','/conversations/messages',ta,{'conversationId':conv,'content':'new round','clientMessageId':'new-'+stamp}) + frame=ws.recv();event=json.loads(frame.split('\n\n',1)[1].rstrip('\x00'));check('new round websocket exact message',event.get('type')=='MESSAGE_CREATED' and event.get('payload',{}).get('id')==text['id'],event);ws.close() + denied('old card remains denied despite new match','GET',oldurl,th,None,8309) + newmsg=good('new round resume card','POST',f'/conversations/{conv}/resume-messages',ta,{'applicationId':c['id'],'clientMessageId':'resume-'+stamp})['id'] + newurl=f'/conversations/{conv}/messages/{newmsg}/resume';r=http('GET',newurl,th);check('new card uses current PDF',r.status_code==200 and r.content==newpdf) + timeline=good('old timeline retained','GET',f"/applications/{a['id']}/timeline",ta);check('old withdrawal in timeline',any(x['toStatus']=='WITHDRAWN' for x in timeline)) + page=good('student list','GET','/applications/me',ta);items=[x for x in page['items'] if x['jobId']==job];check('three rounds listed',len(items)==3 and {x['attemptNumber'] for x in items}=={1,2,3}) + d=good('company candidate detail','GET',f"/company/candidates/{c['id']}",th);check('company detail lineage',d['attemptNumber']==3 and d['previousApplicationId']==b['id'],d) + d=good('company candidate list','GET',f'/company/candidates?jobId={job}',th);check('company list preserves all rounds',len(d['items'])==3 and {x['attemptNumber'] for x in d['items']}=={1,2,3},d) + closed=good('closed fixture job','POST','/jobs',th,{'title':'closed fixture','description':'synthetic','city':'TEST','employmentType':'FULL_TIME'})['id'] + good('publish closed fixture','PATCH',f'/jobs/{closed}/status',th,{'status':'PUBLISHED'}) + app=good('apply closed fixture','POST','/applications',ta,{'jobId':closed})['id'];good('withdraw closed fixture','POST',f'/applications/{app}/withdraw',ta) + good('close fixture job','PATCH',f'/jobs/{closed}/status',th,{'status':'CLOSED'}) + denied('closed job blocks reapply','POST','/applications',ta,{'jobId':closed},7001) + rejected=good('rejected fixture job','POST','/jobs',th,{'title':'rejected fixture','description':'synthetic','city':'TEST','employmentType':'FULL_TIME'})['id'] + good('publish rejected fixture','PATCH',f'/jobs/{rejected}/status',th,{'status':'PUBLISHED'}) + app=good('apply rejected fixture','POST','/applications',ta,{'jobId':rejected})['id'];good('reject fixture application','PATCH',f'/applications/{app}/status',th,{'status':'REJECTED'}) + denied('rejected still blocks reapply','POST','/applications',ta,{'jobId':rejected},8001) + save('blackbox-state.json',{'phones':phones,'job':job,'oldApplication':a['id'],'currentApplication':c['id'],'conversation':conv,'oldUrl':oldurl,'newUrl':newurl,'sha256':hashlib.sha256(newpdf).hexdigest()}) +def recheck(): + s=json.loads((ROOT/'blackbox-state.json').read_text(encoding='utf-8'));ta,th=map(login,s['phones']) + d=good('current application survives restart','GET',f"/applications/{s['currentApplication']}",ta);check('third round persists',d['attemptNumber']==3 and d['status']=='SUBMITTED') + d=good('old application survives restart','GET',f"/applications/{s['oldApplication']}",ta);check('withdrawn state persists',d['status']=='WITHDRAWN') + denied('old card still denied after restart','GET',s['oldUrl'],th,None,8309) + r=http('GET',s['newUrl'],th);check('new authorized bytes persist',r.status_code==200 and hashlib.sha256(r.content).hexdigest()==s['sha256']) + good('chat sending still unlocked by new match','POST','/conversations/messages',ta,{'conversationId':s['conversation'],'content':'restart','clientMessageId':'restart-'+uuid.uuid4().hex}) +if __name__=='__main__': + parser=argparse.ArgumentParser();parser.add_argument('mode',choices=['setup','start','run','recheck']);a=parser.parse_args() + ROOT.mkdir(parents=True,exist_ok=True) + if a.mode in ['run','recheck']: + try:globals()[a.mode]() + finally:save('blackbox-'+a.mode+'-results.json',RESULTS) + else:globals()[a.mode]() diff --git a/docs/acceptance/application-reapplication-20261020/verify_contract.py b/docs/acceptance/application-reapplication-20261020/verify_contract.py new file mode 100644 index 0000000..8787e6c --- /dev/null +++ b/docs/acceptance/application-reapplication-20261020/verify_contract.py @@ -0,0 +1,25 @@ +"""本批静态校验及当前隔离 JAR 的契约对照,不修改契约。""" +import json,pathlib,subprocess,sys,requests +ROOT=pathlib.Path('D:/Workspaces/qsp-reapply-20261020') +REPO=pathlib.Path(__file__).resolve().parents[3] +rows=[] +def check(name,ok,actual=None):rows.append({'case':name,'ok':bool(ok),'actual':actual}) +p=subprocess.run([sys.executable,str(REPO/'docs/verify_openapi.py')],capture_output=True,text=True,encoding='utf-8',errors='replace',env={**__import__('os').environ,'PYTHONIOENCODING':'utf-8'}) +(ROOT/'logs/static-openapi.log').write_text(p.stdout+p.stderr,encoding='utf-8');check('static verifier',p.returncode==0,p.returncode) +static=json.loads((REPO/'docs/openapi-rc.json').read_text(encoding='utf-8')) +r=requests.get('http://127.0.0.1:18090/api/v1/v3/api-docs',timeout=30);check('runtime OpenAPI reachable',r.status_code==200,r.status_code);runtime=r.json() +(ROOT/'runtime-openapi.json').write_text(json.dumps(runtime,ensure_ascii=False),encoding='utf-8') +check('same path identities',set(static['paths'])==set(runtime['paths'])) +methods={'get','post','put','patch','delete','head','options','trace'} +ops=lambda doc:{(p,m) for p,vs in doc['paths'].items() for m in vs if m in methods} +check('same operation identities',ops(static)==ops(runtime)) +for name in ['CandidateItemView','CandidateDetailView']: + for field,fmt in [('attemptNumber','int32'),('previousApplicationId','int64')]: + a=static['components']['schemas'][name]['properties'][field];b=runtime['components']['schemas'][name]['properties'][field] + check(name+'.'+field,a.get('type')==b.get('type')=='integer' and a.get('format')==b.get('format')==fmt,{'static':a,'runtime':b}) + forbidden=set(runtime['components']['schemas'][name]['properties'])&{'studentUserId','phone','email','resumeUrl','storagePath'} + check(name+' remains redacted',not forbidden,sorted(forbidden)) +for path in ['/applications','/applications/{id}/withdraw']: + check(path+' description',static['paths'][path]['post']['description']==runtime['paths'][path]['post']['description']) +(ROOT/'contract-results.json').write_text(json.dumps(rows,ensure_ascii=False,indent=2),encoding='utf-8') +print('CONTRACT',len(rows),'FAIL',sum(not r['ok'] for r in rows));raise SystemExit(any(not r['ok'] for r in rows)) diff --git a/docs/application-reapplication-handover-20261020.md b/docs/application-reapplication-handover-20261020.md new file mode 100644 index 0000000..c71e43a --- /dev/null +++ b/docs/application-reapplication-handover-20261020.md @@ -0,0 +1,67 @@ +# 撤回后重新投递:后端与前端联调约定 + +范围:仅后端重投规则、兼容字段和关联权限;不调整已拒绝后的重投政策,不修改前端,不删除历史数据。测试结果以本批最终验收记录为准;本说明不代表已经上线。 + +## 1. 重投规则 + +| 当前情况 | POST /applications | +| --- | --- | +| 没有投递历史 | 创建首轮,SUBMITTED | +| 同岗位所有历史申请均为 WITHDRAWN | 创建新 id、新轮次,SUBMITTED | +| 存在 SUBMITTED/VIEWED/INTERVIEW | 拒绝重复投递 | +| 存在 REJECTED/HIRED | 仍拒绝,本批不放开 | +| 岗位不是 PUBLISHED | 不允许投递,包括撤回后重投 | + +撤回仍只允许 SUBMITTED/VIEWED,不放开面试中撤回。旧记录不恢复为 SUBMITTED;每轮有独立时间线,旧匹配、面试、Offer 及简历卡片继续关联旧 id。 + +V58 新增 attempt_number、previous_application_id 和按状态生成的 active_application_key。旧记录轮次为 1;仅 WITHDRAWN 的占位键为 NULL。数据库同时限制“同岗位同求职者同轮次唯一”和“同岗位同求职者非撤回申请唯一”。投递事务锁定岗位,检查其发布状态、占位及上一轮,防止并发重投。岗位级锁会串行处理同岗位投递,是本批明确的正确性取舍。 + +## 2. 接口字段与错误码 + +继续调用 `POST /api/v1/applications`,请求不变: + +```json +{"jobId":123} +``` + +重投成功仍返回 HTTP 200,新响应 `data` 示例(其他既有字段保留): + +```json +{"id":456,"jobId":123,"status":"SUBMITTED","attemptNumber":2,"previousApplicationId":321} +``` + +- `id`:本轮投递 ID。后续评分、企业处理、面试、Offer、会话解锁及简历卡片操作必须使用对应轮次的 ID。 +- `attemptNumber`:从 1 开始;用于区分同岗位不同轮次,不代表新的投递状态。 +- `previousApplicationId`:直接上一轮 id;首轮为 null,受全局非空序列化影响也可能省略。 +- 上述字段下发于本人投递列表/详情、企业投递列表、企业候选人列表/详情。候选人 DTO 不新增手机号、用户 ID、简历存储引用等敏感字段。 +- 重复投递沿用 HTTP 409 / `code=8001`,岗位不可投递沿用 HTTP 404 / `code=7001`。不要把所有非 200 都显示为“网络错误”。 +- `code=8003`:撤回/状态流转不允许;`8304`:指定轮次未匹配;`8303`:企业与求职者当前没有有效匹配,不能建立/恢复发送。 + +前端撤回后将该轮显示为“已撤回”,在岗位仍可投递时展示“重新投递”。成功后使用服务端返回的新 id 刷新列表,不覆盖旧记录。防连点只用于体验,后端仍负责并发约束;请求超时后先刷新投递记录,不能擅自推断成功。 + +同岗位多轮全部保留在列表;判断最新轮次用该岗位的最大 attemptNumber,不依赖秒级 createdAt 相同情况下的数组顺序。 + +## 3. 旧会话与简历权限 + +- 新一轮不复制旧匹配分、阈值快照或匹配结果。旧 id 即使保留 MATCHED 历史记录,只要投递已撤回,不能通过该 id 解锁会话。 +- 历史 DIRECT 会话可阅读。对有撤回历史的企业/求职者组合,发送消息和通过通用接口获取旧会话,必须存在双方当前有效 MATCHED 申请。重新匹配后可以复用原会话,不复制历史消息。 +- 沟通解锁仍采用既有“企业—求职者关系”口径:若双方另一个岗位存在有效匹配,可以继续沟通;不能把它描述为每个岗位拥有完全独立的聊天权限。 +- 聊天简历快照继续绑定原 applicationId,撤回后不能因新一轮匹配而恢复旧卡片授权;已显式撤销的卡片不复活。 +- 本批不引入“投递时固化简历”新机制。企业当前简历下载仍读取求职者当前资料,并依据指定 applicationId、企业归属与隐私策略授权;主动发送卡片时才保存不可变快照。 +- PRIVATE 为动态限制;VERIFIED_COMPANIES 等既有显式隐私授权语义不在本批调整。不可将用户面向认证企业的全局授权误称为旧轮次权限恢复。 +- 面试和 Offer 不迁移到新 id,不恢复旧流程。 + +## 4. 前端联调验收步骤 + +1. 使用合成账号和已发布岗位首次投递,记录 id、轮次及列表状态。 +2. 企业查看后,求职者撤回,确认旧时间线仍在。 +3. 同一岗位重投,确认返回新 id、轮次加一、previousApplicationId 指向旧 id,企业收到新的待处理申请。 +4. 连续点击/并发请求,确认最多一条有效申请,其余返回重复投递错误。 +5. 对进行中、已拒绝、已录用和关闭岗位分别验证禁止重投。 +6. 旧轮次匹配不能解锁新轮次;历史聊天可读,新轮次重新满足匹配后才按关系级规则发送。 +7. 用真实结构的合成 PDF 验证旧卡片仍拒绝、新卡片绑定新轮次;更换源简历不能篡改旧快照。 +8. 重启隔离 JAR 后核对两轮记录、时间线和授权行为。 + +发布前还需:真实 MySQL 5.7/8 迁移、专项回归、最新 JAR 黑盒与后续全量验证。前端接入与提交/推送/发布均另行安排。 + +本批本地验证记录(2026-10-20):专项 Maven 测试合计 129 项,0 失败/错误/跳过;新 JAR 黑盒 61 项及重启复验 7 项通过;静态与运行时契约专项 12 项通过。已覆盖真实 MySQL 5.7/8 的 V57→V58 升级及 MySQL 并发。证据与制品在 `D:\Workspaces\qsp-reapply-20261020`。本批完整 `mvn verify` 尚未执行;上述结果不代表线上已经更新。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index f121635..2bb6300 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -964,7 +964,7 @@ "投递" ], "summary": "投递岗位", - "description": "只能投递 PUBLISHED 状态的岗位,不可重复投递", + "description": "只能投递 PUBLISHED 岗位;仅所有历史投递均已撤回时可重投。重投返回新 id、attemptNumber 和 previousApplicationId,不继承旧匹配或授权;进行中、已拒绝、已录用仍返回重复投递错误。", "operationId": "apply", "requestBody": { "content": { @@ -1529,7 +1529,7 @@ "投递" ], "summary": "撤回投递", - "description": "仅 SUBMITTED/VIEWED 状态可撤回,撤回后不可逆", + "description": "仅 SUBMITTED/VIEWED 可撤回;旧记录不恢复,可通过 POST /applications 创建新一轮投递。", "operationId": "withdraw", "parameters": [ { @@ -13793,6 +13793,14 @@ "CandidateDetailView": { "type": "object", "properties": { + "attemptNumber": { + "type": "integer", + "format": "int32" + }, + "previousApplicationId": { + "type": "integer", + "format": "int64" + }, "applicationId": { "type": "integer", "format": "int64" @@ -13960,6 +13968,14 @@ "CandidateItemView": { "type": "object", "properties": { + "attemptNumber": { + "type": "integer", + "format": "int32" + }, + "previousApplicationId": { + "type": "integer", + "format": "int64" + }, "applicationId": { "type": "integer", "format": "int64" diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java index eb67819..f3a0b68 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/Application.java @@ -20,8 +20,11 @@ import java.time.LocalDateTime; @AllArgsConstructor @Entity @Table(name = "applications", - uniqueConstraints = @UniqueConstraint(name = "uk_applications_job_student", - columnNames = {"job_id", "student_user_id"})) + uniqueConstraints = { + @UniqueConstraint(name = "uk_applications_job_student_attempt", + columnNames = {"job_id", "student_user_id", "attempt_number"}), + @UniqueConstraint(name = "uk_applications_job_student_active", + columnNames = {"job_id", "student_user_id", "active_application_key"})}) @EntityListeners(AuditingEntityListener.class) public class Application { @@ -35,6 +38,17 @@ public class Application { @Column(name = "student_user_id", nullable = false) private Long studentUserId; + @Column(name = "attempt_number", nullable = false) + @Builder.Default + private Integer attemptNumber = 1; + + @Column(name = "previous_application_id") + private Long previousApplicationId; + + /** 数据库按状态生成;被拒绝、已录用仍占位,不能借重投绕过终态规则。 */ + @Column(name = "active_application_key", insertable = false, updatable = false) + private Integer activeApplicationKey; + @Column(name = "cover_letter", columnDefinition = "text") private String coverLetter; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java index deea673..f1fd47b 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationController.java @@ -52,7 +52,7 @@ public class ApplicationController { // ── 学生投递 ────────────────────────────────────────────── - @Operation(summary = "投递岗位", description = "只能投递 PUBLISHED 状态的岗位,不可重复投递") + @Operation(summary = "投递岗位", description = "只能投递 PUBLISHED 岗位;仅所有历史投递均已撤回时可重投。重投返回新 id、attemptNumber 和 previousApplicationId,不继承旧匹配或授权;进行中、已拒绝、已录用仍返回重复投递错误。") @PostMapping("/applications") public ResponseEntity>> apply( @Valid @RequestBody ApplicationCreateRequest req, @@ -64,7 +64,7 @@ public class ApplicationController { // ── 学生撤回投递 ────────────────────────────────────────── - @Operation(summary = "撤回投递", description = "仅 SUBMITTED/VIEWED 状态可撤回,撤回后不可逆") + @Operation(summary = "撤回投递", description = "仅 SUBMITTED/VIEWED 可撤回;旧记录不恢复,可通过 POST /applications 创建新一轮投递。") @PostMapping("/applications/{id}/withdraw") public ResponseEntity>> withdraw( @PathVariable Long id, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java index 094f30b..0d4a736 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationRepository.java @@ -11,7 +11,17 @@ import java.util.List; @Repository public interface ApplicationRepository extends JpaRepository { - Optional findByJobIdAndStudentUserId(Long jobId, Long studentUserId); + /** MySQL REPEATABLE READ 下必须当前读,不能在等待岗位锁后继续读取旧快照。 */ + @org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + Optional findFirstByJobIdAndStudentUserIdOrderByAttemptNumberDesc(Long jobId, Long studentUserId); + + @org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + Optional findFirstByJobIdAndStudentUserIdAndStatusNotInOrderByAttemptNumberDesc( + Long jobId, Long studentUserId, java.util.Collection statuses); + + @org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + @org.springframework.data.jpa.repository.Query("select a from Application a where a.id = :id") + Optional findLockedById(@org.springframework.data.repository.query.Param("id") Long id); List findByStudentUserIdOrderByCreatedAtDesc(Long studentUserId); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java index 40d6ede..2f9691a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/ApplicationService.java @@ -74,12 +74,18 @@ public class ApplicationService { public Application apply(Long studentUserId, ApplicationCreateRequest req, com.mydreamjob.audit.AuditContext ctx) { assertRole(studentUserId, "STUDENT"); - Job job = jobService.getJob(req.getJobId()); + Job job = jobService.getJobForApplication(req.getJobId()); if (!"PUBLISHED".equals(job.getStatus())) throw new BusinessException(ErrorCode.JOB_NOT_FOUND); - if (applicationRepository.existsByJobIdAndStudentUserId(req.getJobId(), studentUserId)) + if (applicationRepository.findFirstByJobIdAndStudentUserIdAndStatusNotInOrderByAttemptNumberDesc( + req.getJobId(), studentUserId, Set.of("WITHDRAWN")).isPresent()) throw new BusinessException(ErrorCode.APPLICATION_DUPLICATE); + Application previous = applicationRepository + .findFirstByJobIdAndStudentUserIdOrderByAttemptNumberDesc(req.getJobId(), studentUserId) + .orElse(null); Application app = Application.builder() .jobId(req.getJobId()).studentUserId(studentUserId) + .attemptNumber(previous == null ? 1 : Math.addExact(previous.getAttemptNumber(), 1)) + .previousApplicationId(previous == null ? null : previous.getId()) .status(ApplicationStatus.SUBMITTED.name()).build(); app = applicationRepository.save(app); recordHistory(app.getId(), studentUserId, null, "SUBMITTED", "Student applied"); @@ -105,7 +111,7 @@ public class ApplicationService { public Application withdraw(Long studentUserId, Long applicationId, com.mydreamjob.audit.AuditContext ctx) { assertRole(studentUserId, "STUDENT"); - Application app = applicationRepository.findById(applicationId) + Application app = applicationRepository.findLockedById(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); if (!app.getStudentUserId().equals(studentUserId)) throw new BusinessException(ErrorCode.APPLICATION_OWNER_MISMATCH); @@ -239,7 +245,7 @@ public class ApplicationService { public Application updateStatus(Long companyId, Long applicationId, ApplicationStatusRequest req, com.mydreamjob.audit.AuditContext ctx) { jobService.requireVerifiedCompany(companyId); - Application app = applicationRepository.findById(applicationId) + Application app = applicationRepository.findLockedById(applicationId) .orElseThrow(() -> new BusinessException(ErrorCode.APPLICATION_NOT_FOUND)); Job job = jobService.getJob(app.getJobId()); if (!job.getCompanyId().equals(companyId)) @@ -275,6 +281,8 @@ public class ApplicationService { view.put("jobId", app.getJobId()); view.put("studentUserId", app.getStudentUserId()); view.put("status", app.getStatus()); + view.put("attemptNumber", app.getAttemptNumber()); + view.put("previousApplicationId", app.getPreviousApplicationId()); view.put("createdAt", app.getCreatedAt()); view.put("updatedAt", app.getUpdatedAt()); return view; @@ -336,7 +344,7 @@ public class ApplicationService { String selectClause = "SELECT a.id, a.jobId, j.title, a.status, " + "sp.realName, sp.school, sp.education, sp.graduationYear, " + - "u.avatarUrl, a.createdAt, a.updatedAt"; + "u.avatarUrl, a.createdAt, a.updatedAt, a.attemptNumber, a.previousApplicationId"; String fromClause = "FROM Application a " + "JOIN Job j ON a.jobId = j.id " + "LEFT JOIN StudentProfile sp ON a.studentUserId = sp.userId " + @@ -376,6 +384,8 @@ public class ApplicationService { .candidateAvatarUrl((String) row[8]) .createdAt((LocalDateTime) row[9]) .updatedAt((LocalDateTime) row[10]) + .attemptNumber((Integer) row[11]) + .previousApplicationId((Long) row[12]) .build()).collect(Collectors.toList()); return CandidateListResponse.builder() @@ -433,6 +443,8 @@ public class ApplicationService { return CandidateDetailView.builder() .applicationId(app.getId()) + .attemptNumber(app.getAttemptNumber()) + .previousApplicationId(app.getPreviousApplicationId()) .jobId(app.getJobId()) .jobTitle(job.getTitle()) .jobCity(job.getCity()) diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java index 02ab7a9..923a045 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateDetailView.java @@ -24,6 +24,8 @@ public class CandidateDetailView { private String jobCity; private String jobEmploymentType; private String status; + private Integer attemptNumber; + private Long previousApplicationId; private String coverLetter; private LocalDateTime createdAt; private LocalDateTime updatedAt; @@ -37,4 +39,4 @@ public class CandidateDetailView { private String candidateEducation; private Short candidateGraduationYear; private List candidateSkills; -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java index dee412d..91bb39e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/application/dto/CandidateItemView.java @@ -20,6 +20,8 @@ public class CandidateItemView { private Long jobId; private String jobTitle; private String status; + private Integer attemptNumber; + private Long previousApplicationId; private String candidateName; private String candidateSchool; private String candidateEducation; @@ -27,4 +29,4 @@ public class CandidateItemView { private String candidateAvatarUrl; private LocalDateTime createdAt; private LocalDateTime updatedAt; -} \ No newline at end of file +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java index 6b26064..5d7307e 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/ApplicationMatchRecordRepository.java @@ -13,6 +13,12 @@ public interface ApplicationMatchRecordRepository extends JpaRepository findByApplicationId(Long applicationId); + /** 撤回后的历史直聊只保留阅读,重新发送必须重新取得有效匹配。 */ + @Query("SELECT COUNT(a) > 0 FROM Application a JOIN Job j ON j.id = a.jobId " + + "WHERE j.companyId = :companyId AND a.studentUserId = :candidateId AND a.status = 'WITHDRAWN'") + boolean existsWithdrawnApplication(@Param("companyId") Long companyId, + @Param("candidateId") Long candidateId); + /** 企业(companyId)与候选人(candidateId)之间是否存在已 MATCHED 的投递(用于建会话守卫) */ @Query("SELECT COUNT(r) > 0 FROM ApplicationMatchRecord r " + "JOIN Application a ON a.id = r.applicationId " diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java index 1bbe0c5..e354e22 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/bidirectionalmatch/MatchConversationService.java @@ -67,6 +67,9 @@ public class MatchConversationService { } // 必须已 MATCHED + if ("WITHDRAWN".equals(app.getStatus()) || "REJECTED".equals(app.getStatus())) { + throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED); + } ApplicationMatchRecord record = recordRepository.findByApplicationId(applicationId).orElse(null); if (record == null || !ApplicationMatchRecord.STATUS_MATCHED.equals(record.getStatus())) { throw new BusinessException(ErrorCode.APPLICATION_NOT_MATCHED); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java index db1823e..95b6735 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobRepository.java @@ -14,6 +14,10 @@ import java.util.Optional; @Repository public interface JobRepository extends JpaRepository, JpaSpecificationExecutor { + @org.springframework.data.jpa.repository.Lock(jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + @Query("select j from Job j where j.id = :id") + Optional findLockedById(@Param("id") Long id); + /** 企业资源访问使用归属限定查询,避免取出其他企业的岗位后再区分归属。 */ Optional findByIdAndCompanyId(Long id, Long companyId); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java index d843953..8ae72f9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java @@ -73,9 +73,13 @@ public class JobService { return jobRepository.save(job); } - /** - * 查询岗位详情(含非 PUBLISHED 状态) - */ + /** 必须由投递事务调用;锁定岗位,串行检查发布状态及同岗位重投。 */ + public Job getJobForApplication(Long jobId) { + return jobRepository.findLockedById(jobId) + .orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND)); + } + + /** 查询岗位详情(含非 PUBLISHED 状态)。 */ public Job getJob(Long jobId) { return jobRepository.findById(jobId) .orElseThrow(() -> new BusinessException(ErrorCode.JOB_NOT_FOUND)); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java index 3e1dd40..a143013 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/MessagingService.java @@ -107,6 +107,7 @@ public class MessagingService { Long otherId = req.getParticipantUserId(); if (userId.equals(otherId)) throw new BusinessException(ErrorCode.BAD_REQUEST); if (!userRepository.existsById(otherId)) throw new BusinessException(ErrorCode.USER_NOT_FOUND); + assertWithdrawnPairUnlocked(userId, otherId); // 已有 DIRECT 会话 → 返回原会话(历史会话保持可访问,不删除、不失效) List my = memberRepository.findByUserId(userId); for (ConversationMember m : my) { @@ -155,6 +156,24 @@ public class MessagingService { return user.getRoles() != null && user.getRoles().stream().anyMatch(r -> r == role); } + /** 不删除历史会话;有撤回历史的企业/求职者必须重新取得有效匹配才能发送。 */ + private void assertWithdrawnPairUnlocked(Long userId, Long otherId) { + User me = userRepository.findById(userId).orElse(null); + User other = userRepository.findById(otherId).orElse(null); + if (me == null || other == null) return; + boolean forward = hasRole(me, User.Role.COMPANY) && hasRole(other, User.Role.STUDENT); + boolean reverse = hasRole(me, User.Role.STUDENT) && hasRole(other, User.Role.COMPANY); + if (!forward && !reverse) return; + Long companyUser = forward ? userId : otherId; + Long student = forward ? otherId : userId; + Long companyId = companyRepository.findByOwnerUserId(companyUser).map(Company::getId).orElse(null); + if (companyId != null + && applicationMatchRecordRepository.existsWithdrawnApplication(companyId, student) + && !applicationMatchRecordRepository.existsMatchedApplication(companyId, student)) { + throw new BusinessException(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION); + } + } + private void requireVerifiedIfCompany(Long userId) { User actor = userRepository.findByIdWithRoles(userId).orElse(null); VerifiedCompanyGuard.requireVerifiedIfCompany(actor, @@ -202,6 +221,7 @@ public class MessagingService { for (ConversationMember m : members) { if (!m.getUserId().equals(userId)) { assertNotBlockedBetween(userId, m.getUserId()); + assertWithdrawnPairUnlocked(userId, m.getUserId()); } } if (req.getClientMessageId() != null) { diff --git a/mydreamjob-server/src/main/resources/db/migration/V58__application_reapplication.sql b/mydreamjob-server/src/main/resources/db/migration/V58__application_reapplication.sql new file mode 100644 index 0000000..09e4df3 --- /dev/null +++ b/mydreamjob-server/src/main/resources/db/migration/V58__application_reapplication.sql @@ -0,0 +1,12 @@ +-- 撤回保留历史,新一轮使用新的 application id。仅 WITHDRAWN 释放占位。 +ALTER TABLE applications ADD COLUMN attempt_number INT NOT NULL DEFAULT 1; +ALTER TABLE applications ADD COLUMN previous_application_id BIGINT NULL; +-- MySQL 5.7/8 默认 VIRTUAL;省略 STORED,兼容 H2 MODE=MySQL 的真实迁移测试。 +ALTER TABLE applications ADD COLUMN active_application_key INT GENERATED ALWAYS AS + (CASE WHEN status = 'WITHDRAWN' THEN NULL ELSE 1 END); +ALTER TABLE applications ADD CONSTRAINT uk_applications_job_student_attempt + UNIQUE (job_id, student_user_id, attempt_number); +ALTER TABLE applications ADD CONSTRAINT uk_applications_job_student_active + UNIQUE (job_id, student_user_id, active_application_key); +-- 新索引已覆盖 job 外键所需的前缀,随后移除旧的永久唯一限制。 +ALTER TABLE applications DROP INDEX uk_applications_job_student; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationIntegrationTest.java new file mode 100644 index 0000000..29ca803 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationIntegrationTest.java @@ -0,0 +1,3 @@ +package com.mydreamjob.application; +@org.springframework.test.context.ActiveProfiles("integration-test") +class ApplicationReapplicationIntegrationTest extends ApplicationReapplicationTestBase {} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMigrationTest.java new file mode 100644 index 0000000..81f941b --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMigrationTest.java @@ -0,0 +1,55 @@ +package com.mydreamjob.application; + +import com.mydreamjob.config.MySql57MigrationConfiguration; +import org.flywaydb.core.Flyway; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MySQLContainer; +import java.sql.*; +import static org.junit.jupiter.api.Assertions.*; + +/** 真实 MySQL 5.7/8:V57 原数据升级、生成占位、唯一约束和旧关联保留。 */ +class ApplicationReapplicationMigrationTest { + @Test void mysql57Upgrade() throws Exception { verify("mysql:5.7.36"); } + @Test void mysql8Upgrade() throws Exception { verify("mysql:8.4"); } + Flyway flyway(String url,String user,String pass,String target) { + var config=Flyway.configure().dataSource(url,user,pass).locations("classpath:db/migration"); + if(target!=null) config.target(target); + MySql57MigrationConfiguration.configure(config); + return config.load(); + } + void verify(String image) throws Exception { + try(var mysql=new MySQLContainer<>(image).withDatabaseName("qsp_reapply_migration") + .withUsername("reapply").withPassword("isolated-only") + .withCreateContainerCmdModifier(cmd->cmd.getHostConfig().withMemory(768L*1024*1024))) { + mysql.start();String base=mysql.getJdbcUrl(); + String url=base+(base.contains("?")?"&":"?")+"characterEncoding=UTF-8"; + try(var c=DriverManager.getConnection(url,mysql.getUsername(),mysql.getPassword());var s=c.createStatement()) { + s.execute("ALTER DATABASE qsp_reapply_migration CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); + flyway(url,mysql.getUsername(),mysql.getPassword(),"57").migrate(); + s.execute("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(990001,'19908000001','COMPANY',1,1),(990002,'19908000002','STUDENT',1,1)"); + s.execute("INSERT INTO companies(id,owner_user_id,name,verified) VALUES(990001,990001,'synthetic migration',1)"); + s.execute("INSERT INTO jobs(id,company_id,title,description,city,employment_type,status) VALUES(990001,990001,'synthetic','test','test','FULL_TIME','PUBLISHED'),(990002,990001,'blocked','test','test','FULL_TIME','PUBLISHED')"); + s.execute("INSERT INTO applications(id,job_id,student_user_id,status) VALUES(990001,990001,990002,'WITHDRAWN'),(990002,990002,990002,'REJECTED')"); + s.execute("INSERT INTO application_status_history(application_id,operator_user_id,from_status,to_status) VALUES(990001,990002,'SUBMITTED','WITHDRAWN')"); + s.execute("INSERT INTO application_match_records(application_id,job_id,company_id,candidate_id,status) VALUES(990001,990001,990001,990002,'MATCHED')"); + assertEquals(1,flyway(url,mysql.getUsername(),mysql.getPassword(),null).migrate().migrationsExecuted); + assertEquals("WITHDRAWN",scalar(s,"SELECT status FROM applications WHERE id=990001")); + assertEquals("1",scalar(s,"SELECT attempt_number FROM applications WHERE id=990001")); + assertNull(scalar(s,"SELECT active_application_key FROM applications WHERE id=990001")); + assertEquals("1",scalar(s,"SELECT active_application_key FROM applications WHERE id=990002")); + assertEquals("MATCHED",scalar(s,"SELECT status FROM application_match_records WHERE application_id=990001")); + assertEquals("1",scalar(s,"SELECT COUNT(*) FROM application_status_history WHERE application_id=990001")); + s.execute("INSERT INTO applications(id,job_id,student_user_id,status,attempt_number,previous_application_id) VALUES(990003,990001,990002,'SUBMITTED',2,990001)"); + assertThrows(SQLException.class,()->s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number) VALUES(990001,990002,'SUBMITTED',3)")); + assertThrows(SQLException.class,()->s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number) VALUES(990002,990002,'SUBMITTED',2)")); + s.execute("UPDATE applications SET status='WITHDRAWN' WHERE id=990003"); + s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number,previous_application_id) VALUES(990001,990002,'SUBMITTED',3,990003)"); + assertThrows(SQLException.class,()->s.execute("INSERT INTO applications(job_id,student_user_id,status,attempt_number) VALUES(990001,990002,'WITHDRAWN',2)")); + assertEquals("3",scalar(s,"SELECT COUNT(*) FROM applications WHERE job_id=990001")); + flyway(url,mysql.getUsername(),mysql.getPassword(),null).validate(); + assertEquals(0,flyway(url,mysql.getUsername(),mysql.getPassword(),null).migrate().migrationsExecuted); + } + } + } + String scalar(Statement s,String sql) throws SQLException {try(var r=s.executeQuery(sql)){assertTrue(r.next());return r.getString(1);}} +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMySqlIntegrationTest.java new file mode 100644 index 0000000..44c4a70 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationMySqlIntegrationTest.java @@ -0,0 +1,36 @@ +package com.mydreamjob.application; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.*; +import org.springframework.transaction.annotation.*; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.*; +import java.util.concurrent.*; +import static org.junit.jupiter.api.Assertions.*; + +@ActiveProfiles("mysql-it") @Testcontainers(disabledWithoutDocker=false) +@org.springframework.test.annotation.DirtiesContext(classMode=org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) +class ApplicationReapplicationMySqlIntegrationTest extends ApplicationReapplicationTestBase { + @Container static final MySQLContainer MYSQL=new MySQLContainer<>("mysql:8.4") + .withDatabaseName("qsp_reapply_test").withUsername("reapply").withPassword("isolated-test-only"); + @DynamicPropertySource static void properties(DynamicPropertyRegistry r) { + r.add("spring.datasource.url",MYSQL::getJdbcUrl);r.add("spring.datasource.username",MYSQL::getUsername);r.add("spring.datasource.password",MYSQL::getPassword); + } + @Test @Transactional(propagation=Propagation.NOT_SUPPORTED) + void concurrentReapplicationHasExactlyOneWinner() throws Exception { + var original=apply();service.withdraw(student.getId(),original.getId()); + var ready=new CountDownLatch(4);var start=new CountDownLatch(1);var pool=Executors.newFixedThreadPool(4); + try { + var futures=new java.util.ArrayList>(); + for(int i=0;i<4;i++) futures.add(pool.submit(()->{ready.countDown();assertTrue(start.await(10,TimeUnit.SECONDS)); + try {apply();return true;} catch(com.mydreamjob.common.exception.BusinessException e) { + assertEquals(com.mydreamjob.common.exception.ErrorCode.APPLICATION_DUPLICATE.getCode(),e.getErrorCodeInt());return false;}})); + assertTrue(ready.await(10,TimeUnit.SECONDS));start.countDown();int winners=0; + for(var f:futures) if(f.get(30,TimeUnit.SECONDS)) winners++; + assertEquals(1,winners); + var all=applications.findByJobIdOrderByCreatedAtDesc(job.getId());assertEquals(2,all.size()); + assertEquals(1,all.stream().filter(x->"SUBMITTED".equals(x.getStatus())).count()); + } finally {start.countDown();pool.shutdownNow();} + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationTestBase.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationTestBase.java new file mode 100644 index 0000000..cefc755 --- /dev/null +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationReapplicationTestBase.java @@ -0,0 +1,111 @@ +package com.mydreamjob.application; + +import com.mydreamjob.auth.*; +import com.mydreamjob.company.*; +import com.mydreamjob.job.*; +import com.mydreamjob.application.dto.ApplicationCreateRequest; +import com.mydreamjob.common.exception.*; +import com.mydreamjob.bidirectionalmatch.*; +import com.mydreamjob.messaging.*; +import com.mydreamjob.messaging.dto.*; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest @AutoConfigureMockMvc @Transactional +abstract class ApplicationReapplicationTestBase { + @Autowired ApplicationService service; + @Autowired ApplicationRepository applications; + @Autowired ApplicationStatusHistoryRepository history; + @Autowired UserRepository users; + @Autowired CompanyRepository companies; + @Autowired JobRepository jobs; + @Autowired EntityManager em; + @Autowired ApplicationMatchRecordRepository matches; + @Autowired MatchConversationService matchConversations; + @Autowired MessagingService messaging; + @Autowired ConversationRepository conversations; + @Autowired ConversationMemberRepository members; + @Autowired AuthSessionService sessions; + @Autowired java.time.Clock clock; + @Autowired MockMvc mvc; + @MockBean RedisTemplate redis; + User student, hr; Company company; Job job; + @BeforeEach void fixture() { + student=user(User.Role.STUDENT);hr=user(User.Role.COMPANY); + company=companies.saveAndFlush(Company.builder().ownerUserId(hr.getId()).name("重投隔离测试").verified(true).build()); + job=jobs.saveAndFlush(Job.builder().companyId(company.getId()).title("重投测试岗位").description("合成数据") + .status("PUBLISHED").employmentType("FULL_TIME").city("测试").build()); + } + User user(User.Role role) { + return users.saveAndFlush(User.builder().phone("19"+String.format("%09d",Math.abs(UUID.randomUUID().getMostSignificantBits()%1000000000))) + .role(role).roles(Set.of(role)).active(true).verified(true).build()); + } + Application apply() {var req=new ApplicationCreateRequest();req.setJobId(job.getId());return service.apply(student.getId(),req);} + void error(ErrorCode code,org.junit.jupiter.api.function.Executable f) { + assertEquals(code.getCode(),assertThrows(BusinessException.class,f).getErrorCodeInt()); + } + String bearer(User u) {return "Bearer "+sessions.createSession(u,clock.instant(), + com.mydreamjob.audit.AuditContext.builder().requestId("reapplication-test").build()).accessToken();} + + @Test void withdrawalCreatesNewIdAndPreservesBothTimelines() { + var first=apply();service.withdraw(student.getId(),first.getId());em.flush();em.clear(); + var second=apply();assertNotEquals(first.getId(),second.getId());assertEquals(2,second.getAttemptNumber()); + assertEquals(first.getId(),second.getPreviousApplicationId());assertEquals("SUBMITTED",second.getStatus()); + assertEquals("WITHDRAWN",applications.findById(first.getId()).orElseThrow().getStatus()); + assertEquals(2,history.findByApplicationIdOrderByCreatedAtAsc(first.getId()).size()); + assertEquals(1,history.findByApplicationIdOrderByCreatedAtAsc(second.getId()).size()); + assertTrue(matches.findByApplicationId(second.getId()).isEmpty()); + error(ErrorCode.APPLICATION_DUPLICATE,this::apply); + } + @Test void allNonWithdrawnStatesStillBlockReapplication() { + var a=apply(); + for(String state:List.of("SUBMITTED","VIEWED","INTERVIEW","REJECTED","HIRED")) { + a.setStatus(state);applications.saveAndFlush(a);error(ErrorCode.APPLICATION_DUPLICATE,this::apply); + } + } + @Test void closedJobAndOtherStudentCannotBypassRules() { + var a=apply();var other=user(User.Role.STUDENT); + error(ErrorCode.APPLICATION_OWNER_MISMATCH,()->service.withdraw(other.getId(),a.getId())); + service.withdraw(student.getId(),a.getId());job.setStatus("CLOSED");jobs.saveAndFlush(job); + error(ErrorCode.JOB_NOT_FOUND,this::apply); + } + @Test void listsAndHttpExposeNewAttemptWithoutReplacingHistory() throws Exception { + var a=apply();service.withdraw(student.getId(),a.getId());var b=apply(); + var view=service.listStudentApplicationViews(student.getId(),null,0,20); + assertEquals(2,view.getTotalElements());assertTrue(view.stream().anyMatch(x->Integer.valueOf(2).equals(x.get("attemptNumber")))); + var candidatePage=service.listCompanyCandidates(company.getId(),job.getId(),null,null,null,null,0,20); + assertEquals(Set.of(1,2),candidatePage.getItems().stream().map(com.mydreamjob.application.dto.CandidateItemView::getAttemptNumber).collect(java.util.stream.Collectors.toSet())); + mvc.perform(get("/applications/"+b.getId()).header("Authorization",bearer(student))) + .andExpect(status().isOk()).andExpect(jsonPath("$.data.attemptNumber").value(2)) + .andExpect(jsonPath("$.data.previousApplicationId").value(a.getId())); + } + @Test void historicalMatchCannotUnlockNewAttemptOrExistingChatUntilRematched() { + var a=apply();matches.saveAndFlush(ApplicationMatchRecord.builder().applicationId(a.getId()).jobId(job.getId()) + .companyId(company.getId()).candidateId(student.getId()).status("MATCHED").candidateInterestScore(90).hrSuitabilityScore(90) + .companyRequiredCandidateScore(60).studentRequiredHrScore(60).build()); + var conv=matchConversations.createOrGetMatchConversation(student.getId(),a.getId());long conversation=((Number)conv.get("id")).longValue(); + service.withdraw(student.getId(),a.getId());var next=apply(); + error(ErrorCode.APPLICATION_NOT_MATCHED,()->matchConversations.createOrGetMatchConversation(student.getId(),a.getId())); + error(ErrorCode.APPLICATION_NOT_MATCHED,()->matchConversations.createOrGetMatchConversation(student.getId(),next.getId())); + var send=new SendMessageRequest();send.setConversationId(conversation);send.setContent("不能沿用旧匹配"); + error(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,()->messaging.sendMessage(student.getId(),send)); + var create=new CreateConversationRequest();create.setParticipantUserId(student.getId()); + error(ErrorCode.MATCH_REQUIRED_FOR_CONVERSATION,()->messaging.createOrGetDirectConversation(hr.getId(),create)); + assertNotNull(messaging.getConversation(conversation,student.getId()),"历史仍可阅读"); + matches.saveAndFlush(ApplicationMatchRecord.builder().applicationId(next.getId()).jobId(job.getId()) + .companyId(company.getId()).candidateId(student.getId()).status("MATCHED").candidateInterestScore(90).hrSuitabilityScore(90) + .companyRequiredCandidateScore(60).studentRequiredHrScore(60).build()); + assertNotNull(messaging.sendMessage(student.getId(),send).get("id")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java index a8d013a..be31952 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/application/ApplicationServiceTest.java @@ -73,7 +73,7 @@ class ApplicationServiceTest { @Test void withdraw_submitted_shouldSucceed() { mockStudentRole(); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + when(applicationRepository.findLockedById(APP_ID)).thenReturn(Optional.of(application)); when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -84,7 +84,7 @@ class ApplicationServiceTest { @Test void withdraw_wrongStudent_shouldReturn403() { mockStudentRole(); application.setStudentUserId(999L); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + when(applicationRepository.findLockedById(APP_ID)).thenReturn(Optional.of(application)); BusinessException ex = assertThrows(BusinessException.class, () -> applicationService.withdraw(STUDENT_ID, APP_ID)); @@ -94,7 +94,7 @@ class ApplicationServiceTest { @Test void withdraw_alreadyWithdrawn_shouldReturn409() { mockStudentRole(); application.setStatus("WITHDRAWN"); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + when(applicationRepository.findLockedById(APP_ID)).thenReturn(Optional.of(application)); BusinessException ex = assertThrows(BusinessException.class, () -> applicationService.withdraw(STUDENT_ID, APP_ID)); @@ -104,7 +104,7 @@ class ApplicationServiceTest { @Test void withdraw_rejected_shouldReturn409() { mockStudentRole(); application.setStatus("REJECTED"); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + when(applicationRepository.findLockedById(APP_ID)).thenReturn(Optional.of(application)); BusinessException ex = assertThrows(BusinessException.class, () -> applicationService.withdraw(STUDENT_ID, APP_ID)); @@ -141,8 +141,7 @@ class ApplicationServiceTest { @Test void apply_shouldRecordHistory() { mockStudentRole(); ApplicationCreateRequest req = new ApplicationCreateRequest(); req.setJobId(JOB_ID); - when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); - when(applicationRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(false); + when(jobService.getJobForApplication(JOB_ID)).thenReturn(publishedJob); when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -154,7 +153,7 @@ class ApplicationServiceTest { @Test void updateStatus_shouldRecordHistory() { ApplicationStatusRequest req = new ApplicationStatusRequest(); req.setStatus("VIEWED"); - when(applicationRepository.findById(APP_ID)).thenReturn(Optional.of(application)); + when(applicationRepository.findLockedById(APP_ID)).thenReturn(Optional.of(application)); when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -168,8 +167,7 @@ class ApplicationServiceTest { @Test void apply_shouldSucceed() { mockStudentRole(); ApplicationCreateRequest req = new ApplicationCreateRequest(); req.setJobId(JOB_ID); - when(jobService.getJob(JOB_ID)).thenReturn(publishedJob); - when(applicationRepository.existsByJobIdAndStudentUserId(JOB_ID, STUDENT_ID)).thenReturn(false); + when(jobService.getJobForApplication(JOB_ID)).thenReturn(publishedJob); when(applicationRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); when(historyRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); @@ -197,7 +195,7 @@ class ApplicationServiceTest { String name, String school, String edu, Short gradYear, String avatarUrl) { return new Object[]{appId, jobId, jobTitle, status, name, school, edu, gradYear, avatarUrl, - java.time.LocalDateTime.now(), java.time.LocalDateTime.now()}; + java.time.LocalDateTime.now(), java.time.LocalDateTime.now(), 1, null}; } @Test void listCompanyCandidates_shouldReturnPaginatedResults() { diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlMigrationTest.java index 21b7420..039554d 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlMigrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlMigrationTest.java @@ -81,7 +81,7 @@ class JobCompanyDisplayFieldsMysqlMigrationTest { } // 3) 升级到 V57 - MigrateResult latest = flyway(url, user, pass, null).migrate(); + MigrateResult latest = flyway(url, user, pass, "57").migrate(); assertTrue(latest.success, "升级到 V57 应成功"); assertEquals(1, latest.migrationsExecuted, "从 V56 升级应只执行一条迁移(V57)"); @@ -117,7 +117,7 @@ class JobCompanyDisplayFieldsMysqlMigrationTest { assertNull(scalar(url, user, pass, "SELECT salary_months FROM jobs WHERE title='B01旧岗位'")); // 6) 重复迁移无待执行项;V57 只有一条历史且成功;新增列不重复 - MigrateResult again = flyway(url, user, pass, null).migrate(); + MigrateResult again = flyway(url, user, pass, "57").migrate(); assertEquals(0, again.migrationsExecuted, "再次迁移应无待执行项"); assertEquals(1, count(url, user, pass, "SELECT count(*) FROM flyway_schema_history WHERE version='57' AND success=1"), @@ -140,7 +140,7 @@ class JobCompanyDisplayFieldsMysqlMigrationTest { String pass = empty.getPassword(); ensureUtf8mb4(url, user, pass, empty.getDatabaseName()); - MigrateResult fromScratch = flyway(url, user, pass, null).migrate(); + MigrateResult fromScratch = flyway(url, user, pass, "57").migrate(); assertTrue(fromScratch.success, "空库应能完整迁移到 V57"); assertEquals("57", scalar(url, user, pass, "SELECT version FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 1"),